Add support for a declarative (via configuration) way to specify Kibana feature overrides (#180362)

## Summary

This PR extends the features plugin to accept feature definition
overrides via Kibana configuration. The functionality is limited to the
Serverless offering only. Additionally, the PR updates Kibana serverless
configurations to include overrides based on the "simplified feature
toggles" proposals discussed with the solution teams.

The configuration might look like this:

```yaml
## Fine-tune the feature privileges.
xpack.features.overrides:
  dashboard:
    privileges:
      ### Dashboard's `All` feature privilege should implicitly
      ### grant `All` access to Maps and Visualize features.
      all.composedOf:
        - feature: "maps"
          privileges: [ "all" ]
        - feature: "visualize"
          privileges: [ "all" ]
    ### All Dashboard sub-feature privileges should be hidden: 
    ### reporting capabilities will be granted via dedicated
    ### Reporting feature and short URL sub-feature privilege
    ### should be granted for both `All` and `Read`.
    subFeatures.privileges:
      download_csv_report.disabled: true
      url_create:
        disabled: true
        includeIn: "read"
  ### Maps feature is disabled since it's automatically granted by Dashboard feature.
  maps.disabled: true
```


## How to test

Log in as the `admin` using SAML and navigate to the `Custom roles`
management section to edit role and see tuned role management UX:

<p align="center">
<img
src="ad6e4b07-53bd-4f5a-ae91-66d6534c711a"
/>
<img
src="8ab4d5a3-f719-42d5-a278-3aee87603c33"
/>
</p>


![image](5e27a49b-4382-4a91-bb85-eca929a27961)

### Search project
```bash
yarn es serverless --projectType=es --ssl -E xpack.security.authc.native_roles.enabled=true
yarn start --serverless=es --ssl --xpack.security.roleManagementEnabled=true
```

Refer to the proposal document, `config/serverless.yml`, and
`config/serverless.es.yml` in this PR to see the specific changes made
for your project type:


![image](9f9d0341-32a1-4258-be3b-d3a809f5bacc)

Create a custom `custom-search` role and re-login as the user with this
role to test your project type (you need to manually type role name if
the role selector):

<p align="center">
<img
src="5088320b-3cc8-4de9-984c-d70fc6277659"
/>
</p>

### Observability project
```bash
yarn es serverless --projectType=oblt --ssl -E xpack.security.authc.native_roles.enabled=true
yarn start --serverless=oblt --ssl --xpack.security.roleManagementEnabled=true
```

Refer to the proposal document, `config/serverless.yml`, and
`config/serverless.oblt.yml` in this PR to see the specific changes made
for your project type:


![image](1d2b360a-24ab-47f7-ac9b-8ad944949c32)

Create a custom `custom-o11y` role and re-login as the user with this
role to test your project type (you need to manually type role name if
the role selector):

<p align="center">
<img
src="110572b1-f08a-4427-a687-5c2e0240a36b"
/>
</p>

### Security project
```bash
yarn es serverless --projectType=security --ssl -E xpack.security.authc.native_roles.enabled=true
yarn start --serverless=security --ssl --xpack.security.roleManagementEnabled=true
```

Refer to the proposal document, `config/serverless.yml`, and
`config/serverless.security.yml` in this PR to see the specific changes
made for your project type:


![image](2dbca002-59f1-44f0-9ab2-1dd205e48da8)

Create a custom `custom-security` role and re-login as the user with
this role to test your project type (you need to manually type role name
if the role selector):

<p align="center">
<img
src="2bec6ae2-8d19-4142-a479-9a81bc1fca14"
/>
</p>

__Fixes: https://github.com/elastic/kibana/issues/178963__

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Aleh Zasypkin 2024-06-06 16:55:19 +03:00 committed by GitHub
parent dd1864b876
commit 53b445833f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 15365 additions and 72 deletions

7
.github/CODEOWNERS vendored
View file

@ -1249,6 +1249,10 @@ x-pack/test/observability_ai_assistant_functional @elastic/obs-ai-assistant
# Core
/config/ @elastic/kibana-core
/config/serverless.yml @elastic/kibana-core @elastic/kibana-security
/config/serverless.es.yml @elastic/kibana-core @elastic/kibana-security
/config/serverless.oblt.yml @elastic/kibana-core @elastic/kibana-security
/config/serverless.security.yml @elastic/kibana-core @elastic/kibana-security
/typings/ @elastic/kibana-core
/test/analytics @elastic/kibana-core
/packages/kbn-test/src/jest/setup/mocks.kbn_i18n_react.js @elastic/kibana-core
@ -1307,6 +1311,9 @@ x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @elastic/kib
/x-pack/test/spaces_api_integration/ @elastic/kibana-security
/x-pack/test/saved_object_api_integration/ @elastic/kibana-security
/x-pack/test_serverless/**/test_suites/common/platform_security/ @elastic/kibana-security
/x-pack/test_serverless/**/test_suites/search/platform_security/ @elastic/kibana-security
/x-pack/test_serverless/**/test_suites/security/platform_security/ @elastic/kibana-security
/x-pack/test_serverless/**/test_suites/observability/platform_security/ @elastic/kibana-security
/packages/core/http/core-http-server-internal/src/cdn_config/ @elastic/kibana-security @elastic/kibana-core
#CC# /x-pack/plugins/security/ @elastic/kibana-security

View file

@ -12,6 +12,22 @@ xpack.serverless.observability.enabled: false
enterpriseSearch.enabled: false
xpack.fleet.enabled: false
xpack.observabilityAIAssistant.enabled: false
xpack.osquery.enabled: false
## Fine-tune the search solution feature privileges. Also, refer to `serverless.yml` for the project-agnostic overrides.
xpack.features.overrides:
### Dashboards feature is moved from Analytics category to the Search one.
dashboard.category: "enterpriseSearch"
### Dev Tools feature is moved from Analytics category to the Search one.
dev_tools.category: "enterpriseSearch"
### Discover feature is moved from Analytics category to the Search one.
discover.category: "enterpriseSearch"
### Machine Learning feature is moved from Analytics category to the Management one.
ml.category: "management"
### Stack Alerts feature is moved from Analytics category to the Search one renamed to simply `Alerts`.
stackAlerts:
name: "Alerts"
category: "enterpriseSearch"
## Cloud settings
xpack.cloud.serverless.project_type: search

View file

@ -8,6 +8,104 @@ xpack.uptime.enabled: true
xpack.securitySolution.enabled: false
xpack.search.notebooks.enabled: false
## Fine-tune the observability solution feature privileges. Also, refer to `serverless.yml` for the project-agnostic overrides.
xpack.features.overrides:
### Applications feature privileges are fine-tuned to grant access to Logs, and Observability apps.
apm:
### By default, this feature named as `APM and User Experience`, but should be renamed to `Applications`.
name: "Applications"
privileges:
# Infrastructure's `All` feature privilege should implicitly grant `All` access to Logs and Observability apps.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
- feature: "observability"
privileges: [ "all" ]
# Infrastructure's `Read` feature privilege should implicitly grant `Read` access to Logs and Observability apps.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
- feature: "observability"
privileges: [ "read" ]
### Dashboards feature should be moved from Analytics category to the Observability one.
dashboard.category: "observability"
### Discover feature should be moved from Analytics category to the Observability one and its privileges are
### fine-tuned to grant access to Observability app.
discover:
category: "observability"
privileges:
# Discover `All` feature privilege should implicitly grant `All` access to Observability app.
all.composedOf:
- feature: "observability"
privileges: [ "all" ]
# Discover `Read` feature privilege should implicitly grant `Read` access to Observability app.
read.composedOf:
- feature: "observability"
privileges: [ "read" ]
### Fleet feature privileges are fine-tuned to grant access to Logs app.
fleetv2:
privileges:
# Fleet `All` feature privilege should implicitly grant `All` access to Logs app.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
# Fleet `Read` feature privilege should implicitly grant `Read` access to Logs app.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
### Infrastructure feature privileges are fine-tuned to grant access to Logs, and Observability apps.
infrastructure:
### By default, this feature named as `Metrics`, but should be renamed to `Infrastructure`.
name: "Infrastructure"
privileges:
# Infrastructure's `All` feature privilege should implicitly grant `All` access to Logs and Observability apps.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
- feature: "observability"
privileges: [ "all" ]
# Infrastructure's `Read` feature privilege should implicitly grant `Read` access to Logs and Observability apps.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
- feature: "observability"
privileges: [ "read" ]
### Logs feature is hidden in Role management since it's automatically granted by either Infrastructure, or Applications features.
logs.hidden: true
### Machine Learning feature should be moved from Analytics category to the Observability one and renamed to `AI Ops`.
ml:
category: "observability"
order: 1200
### Observability feature is hidden in Role management since it's automatically granted by either Discover,
### Infrastructure, Applications, Synthetics, or SLOs features.
observability.hidden: true
### SLOs feature privileges are fine-tuned to grant access to Observability app.
slo:
privileges:
# SLOs `All` feature privilege should implicitly grant `All` access to Observability app.
all.composedOf:
- feature: "observability"
privileges: [ "all" ]
# SLOs `Read` feature privilege should implicitly grant `Read` access to Observability app.
read.composedOf:
- feature: "observability"
privileges: [ "read" ]
### Stack alerts is hidden in Role management since it's not needed.
stackAlerts.hidden: true
### Synthetics feature privileges are fine-tuned to grant access to Observability app.
uptime:
### By default, this feature named as `Synthetics and Uptime`, but should be renamed to `Synthetics` since `Uptime` is not available.
name: "Synthetics"
privileges:
# Synthetics `All` feature privilege should implicitly grant `All` access to Observability app.
all.composedOf:
- feature: "observability"
privileges: [ "all" ]
# Synthetics `Read` feature privilege should implicitly grant `Read` access to Observability app.
read.composedOf:
- feature: "observability"
privileges: [ "read" ]
## Enable the slo plugin
xpack.slo.enabled: true

View file

@ -9,6 +9,43 @@ xpack.observability.enabled: false
xpack.observabilityAIAssistant.enabled: false
xpack.search.notebooks.enabled: false
## Fine-tune the security solution feature privileges. Also, refer to `serverless.yml` for the project-agnostic overrides.
xpack.features.overrides:
### Dashboard feature is hidden in Role management since it's automatically granted by SIEM feature.
dashboard.hidden: true
### Discover feature is hidden in Role management since it's automatically granted by SIEM feature.
discover.hidden: true
### Machine Learning feature is moved from Analytics category to the Security one as the last item.
ml:
category: "security"
order: 1101
### Security's feature privileges are fine-tuned to grant access to Discover, Dashboard, Maps, and Visualize apps.
siem:
privileges:
### Security's `All` feature privilege should implicitly grant `All` access to Discover, Dashboard, Maps, and
### Visualize features.
all.composedOf:
- feature: "discover"
privileges: [ "all" ]
- feature: "dashboard"
privileges: [ "all" ]
- feature: "visualize"
privileges: [ "all" ]
- feature: "maps"
privileges: [ "all" ]
# Security's `Read` feature privilege should implicitly grant `Read` access to Discover, Dashboard, Maps, and
# Visualize features. Additionally, it should implicitly grant privilege to create short URLs in Discover,
### Dashboard, and Visualize apps.
read.composedOf:
- feature: "discover"
privileges: [ "read" ]
- feature: "dashboard"
privileges: [ "read" ]
- feature: "visualize"
privileges: [ "read" ]
- feature: "maps"
privileges: [ "read" ]
## Cloud settings
xpack.cloud.serverless.project_type: security

View file

@ -8,6 +8,59 @@ xpack.fleet.internal.activeAgentsSoftLimit: 25000
xpack.fleet.internal.onlyAllowAgentUpgradeToKnownVersions: true
xpack.fleet.internal.retrySetupOnBoot: true
## Fine-tune the feature privileges.
xpack.features.overrides:
dashboard:
privileges:
### Dashboard's `All` feature privilege should implicitly grant `All` access to Maps and Visualize features.
all.composedOf:
- feature: "maps"
privileges: [ "all" ]
- feature: "visualize"
privileges: [ "all" ]
### Dashboard's `Read` feature privilege should implicitly grant `Read` access to Maps and Visualize features.
### Additionally, it should implicitly grant privilege to create short URLs in Visualize app.
read.composedOf:
- feature: "maps"
privileges: [ "read" ]
- feature: "visualize"
privileges: [ "read" ]
### All Dashboard sub-feature privileges should be hidden: reporting capabilities will be granted via dedicated
### Reporting feature and short URL sub-feature privilege should be granted for both `All` and `Read`.
subFeatures.privileges:
download_csv_report.disabled: true
generate_report.disabled: true
store_search_session.disabled: true
url_create:
disabled: true
includeIn: "read"
discover:
### All Discover sub-feature privileges should be hidden: reporting capabilities will be granted via dedicated
### Reporting feature and short URL sub-feature privilege should be granted for both `All` and `Read`.
subFeatures.privileges:
generate_report.disabled: true
store_search_session.disabled: true
url_create:
disabled: true
includeIn: "read"
### Shared images feature is hidden in Role management since it's not needed.
filesSharedImage.hidden: true
### Maps feature is hidden in Role management since it's automatically granted by Dashboard feature.
maps.hidden: true
### Reporting feature is supposed to give access to reporting capabilities across different features.
reporting:
privileges:
all.composedOf:
- feature: "dashboard"
privileges: [ "download_csv_report" ]
- feature: "discover"
privileges: [ "generate_report" ]
### Visualize feature is hidden in Role management since it's automatically granted by Dashboard feature.
visualize:
hidden: true
### The short URL sub-feature privilege should be always granted.
subFeatures.privileges.url_create.includeIn: "read"
# Cloud links
xpack.cloud.base_url: 'https://cloud.elastic.co'

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { FeatureKibanaPrivilegesReference } from './feature_kibana_privileges_reference';
/**
* Feature privilege definition
*/
@ -263,4 +265,11 @@ export interface FeatureKibanaPrivileges {
* @see UICapabilities
*/
ui: readonly string[];
/**
* An optional list of other registered feature or sub-feature privileges that this privilege is composed of. When
* privilege is registered with Elasticsearch, it will be expanded to grant everything that referenced privileges
* grant. This property can only be set in the feature configuration overrides.
*/
composedOf?: readonly FeatureKibanaPrivilegesReference[];
}

View file

@ -0,0 +1,20 @@
/*
* 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.
*/
/**
* Defines a reference to a set of privileges of a specific feature.
*/
export interface FeatureKibanaPrivilegesReference {
/**
* The ID of the feature.
*/
feature: string;
/**
* The set of IDs of feature or sub-feature privileges provided by the feature.
*/
privileges: readonly string[];
}

View file

@ -18,3 +18,4 @@ export type {
SubFeaturePrivilegeGroupType,
} from './sub_feature';
export { SubFeature } from './sub_feature';
export type { FeatureKibanaPrivilegesReference } from './feature_kibana_privileges_reference';

View file

@ -142,6 +142,13 @@ export interface KibanaFeatureConfig {
description: string;
privileges: readonly ReservedKibanaPrivilege[];
};
/**
* Indicates whether the feature is available as a standalone feature. The feature can still be
* referenced by other features, but it will not be displayed in any feature management UIs. By default, all features
* are visible.
*/
hidden?: boolean;
}
export class KibanaFeature {
@ -157,6 +164,10 @@ export class KibanaFeature {
return this.config.id;
}
public get hidden() {
return this.config.hidden;
}
public get name() {
return this.config.name;
}

View file

@ -70,7 +70,7 @@ export interface SubFeaturePrivilegeGroupConfig {
* Configuration for a sub-feature privilege.
*/
export interface SubFeaturePrivilegeConfig
extends Omit<FeatureKibanaPrivileges, 'excludeFromBasePrivileges'> {
extends Omit<FeatureKibanaPrivileges, 'excludeFromBasePrivileges' | 'composedOf'> {
/**
* Identifier for this privilege. Must be unique across all other privileges within a feature.
*/

View file

@ -0,0 +1,160 @@
/*
* 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 { ConfigSchema } from './config';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
describe('config schema', () => {
it('generates proper defaults (no overrides)', () => {
expect(ConfigSchema.validate({})).toMatchInlineSnapshot(`Object {}`);
expect(ConfigSchema.validate({}, { serverless: true })).toMatchInlineSnapshot(`Object {}`);
});
it('does not allow overrides in non-serverless', () => {
expect(() =>
ConfigSchema.validate(
{ overrides: { featureA: { name: 'new name' } } },
{ serverless: false }
)
).toThrowErrorMatchingInlineSnapshot(`"[overrides]: a value wasn't expected to be present"`);
expect(
ConfigSchema.validate({ overrides: { featureA: { name: 'new name' } } }, { serverless: true })
).toMatchInlineSnapshot(`
Object {
"overrides": Object {
"featureA": Object {
"name": "new name",
},
},
}
`);
});
it('can override feature properties', () => {
expect(
ConfigSchema.validate(
{
overrides: {
featureA: { name: 'new name', hidden: true },
featureB: {
order: 100,
category: 'management',
privileges: {
all: {
disabled: true,
},
read: {
composedOf: [{ feature: 'featureC', privileges: ['all', 'read'] }],
},
},
},
featureC: {
subFeatures: {
privileges: {
subOne: {
disabled: true,
includeIn: 'all',
},
subTwo: {
includeIn: 'none',
},
},
},
},
},
},
{ serverless: true }
)
).toMatchInlineSnapshot(`
Object {
"overrides": Object {
"featureA": Object {
"hidden": true,
"name": "new name",
},
"featureB": Object {
"category": "management",
"order": 100,
"privileges": Object {
"all": Object {
"disabled": true,
},
"read": Object {
"composedOf": Array [
Object {
"feature": "featureC",
"privileges": Array [
"all",
"read",
],
},
],
},
},
},
"featureC": Object {
"subFeatures": Object {
"privileges": Object {
"subOne": Object {
"disabled": true,
"includeIn": "all",
},
"subTwo": Object {
"includeIn": "none",
},
},
},
},
},
}
`);
});
it('properly validates category override', () => {
for (const category of Object.keys(DEFAULT_APP_CATEGORIES)) {
expect(
ConfigSchema.validate({ overrides: { featureA: { category } } }, { serverless: true })
.overrides?.featureA.category
).toBe(category);
}
expect(() =>
ConfigSchema.validate(
{ overrides: { featureA: { category: 'unknown' } } },
{ serverless: true }
)
).toThrowErrorMatchingInlineSnapshot(
`"[overrides.featureA.category]: Unknown category \\"unknown\\". Should be one of kibana, enterpriseSearch, observability, security, management"`
);
});
it('properly validates sub-feature privilege inclusion override', () => {
for (const includeIn of ['all', 'read', 'none']) {
expect(
ConfigSchema.validate(
{ overrides: { featureA: { subFeatures: { privileges: { subOne: { includeIn } } } } } },
{ serverless: true }
).overrides?.featureA.subFeatures?.privileges.subOne.includeIn
).toBe(includeIn);
}
expect(() =>
ConfigSchema.validate(
{
overrides: {
featureA: { subFeatures: { privileges: { subOne: { includeIn: 'write' } } } },
},
},
{ serverless: true }
)
).toThrowErrorMatchingInlineSnapshot(`
"[overrides.featureA.subFeatures.privileges.subOne.includeIn]: types that failed validation:
- [includeIn.0]: expected value to equal [all]
- [includeIn.1]: expected value to equal [read]
- [includeIn.2]: expected value to equal [none]"
`);
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 { offeringBasedSchema, schema, type TypeOf } from '@kbn/config-schema';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
const privilegeOverrideSchema = schema.maybe(
schema.object({
disabled: schema.maybe(schema.boolean()),
composedOf: schema.maybe(
schema.arrayOf(
schema.object({
feature: schema.string(),
privileges: schema.arrayOf(schema.string()),
})
)
),
})
);
export type ConfigType = TypeOf<typeof ConfigSchema>;
export type ConfigOverridesType = Required<ConfigType>['overrides'];
export const ConfigSchema = schema.object({
overrides: offeringBasedSchema({
// Overrides are only exposed in Serverless offering.
serverless: schema.maybe(
// Key is the feature ID, value is a set of feature properties to override.
schema.recordOf(
schema.string(),
schema.object({
hidden: schema.maybe(schema.boolean()),
name: schema.maybe(schema.string({ minLength: 1 })),
category: schema.maybe(
schema.string({
validate(categoryName) {
if (!Object.hasOwn(DEFAULT_APP_CATEGORIES, categoryName)) {
return `Unknown category "${categoryName}". Should be one of ${Object.keys(
DEFAULT_APP_CATEGORIES
).join(', ')}`;
}
},
})
),
order: schema.maybe(schema.number()),
privileges: schema.maybe(
schema.object({ all: privilegeOverrideSchema, read: privilegeOverrideSchema })
),
subFeatures: schema.maybe(
schema.object({
// Key is the ID of the sub-feature privilege, value is a set of privilege properties to override.
privileges: schema.recordOf(
schema.string(),
schema.object({
disabled: schema.maybe(schema.boolean()),
includeIn: schema.maybe(
schema.oneOf([
schema.literal('all'),
schema.literal('read'),
schema.literal('none'),
])
),
})
),
})
),
})
)
),
}),
});

View file

@ -8,6 +8,7 @@
import { FeatureRegistry } from './feature_registry';
import { ElasticsearchFeatureConfig, KibanaFeatureConfig } from '../common';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
describe('FeatureRegistry', () => {
describe('Kibana Features', () => {
@ -1930,6 +1931,270 @@ describe('FeatureRegistry', () => {
expect(withSubFeature.subFeatures[0].privilegeGroups[0].privileges).toHaveLength(0);
});
});
describe('#applyOverrides', () => {
let registry: FeatureRegistry;
beforeEach(() => {
registry = new FeatureRegistry();
const features: KibanaFeatureConfig[] = [
{
id: 'featureA',
name: 'Feature A',
app: [],
order: 1,
category: { id: 'fooA', label: 'fooA' },
privileges: {
all: { ui: [], savedObject: { all: [], read: [] } },
read: { ui: [], savedObject: { all: [], read: [] } },
},
},
{
id: 'featureB',
name: 'Feature B',
app: [],
order: 2,
category: { id: 'fooB', label: 'fooB' },
privileges: null,
},
{
id: 'featureC',
name: 'Feature C',
app: [],
order: 1,
category: { id: 'fooC', label: 'fooC' },
privileges: {
all: { ui: [], savedObject: { all: [], read: [] } },
read: { ui: [], savedObject: { all: [], read: [] } },
},
subFeatures: [
{
name: 'subFeatureC',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
id: 'subFeatureCOne',
name: 'subFeature C One',
includeIn: 'all',
ui: [],
savedObject: { all: [], read: [] },
},
],
},
],
},
],
},
{
id: 'featureD',
name: 'Feature D',
app: [],
order: 1,
category: { id: 'fooD', label: 'fooD' },
privileges: {
all: { ui: [], savedObject: { all: [], read: [] } },
read: { ui: [], savedObject: { all: [], read: [] } },
},
},
{
id: 'featureE',
name: 'Feature E',
app: [],
order: 1,
category: { id: 'fooE', label: 'fooE' },
privileges: {
all: {
ui: [],
savedObject: { all: [], read: [] },
alerting: { alert: { all: ['one'] } },
},
read: { ui: [], savedObject: { all: [], read: [] } },
},
alerting: ['one'],
},
];
features.forEach((f) => registry.registerKibanaFeature(f));
});
it('rejects overrides for unknown features', () => {
expect(() =>
registry.applyOverrides({ unknownFeature: {} })
).toThrowErrorMatchingInlineSnapshot(
`"Cannot override feature \\"unknownFeature\\" since feature with such ID is not registered."`
);
});
it('can override basic feature properties', () => {
registry.applyOverrides({
featureA: {
hidden: true,
name: 'Feature A New',
category: 'management',
order: 123,
},
});
registry.lockRegistration();
const [featureA, featureB] = registry.getAllKibanaFeatures();
expect(featureA.hidden).toBe(true);
expect(featureB.hidden).toBeUndefined();
expect(featureA.name).toBe('Feature A New');
expect(featureB.name).toBe('Feature B');
expect(featureA.category).toEqual(DEFAULT_APP_CATEGORIES.management);
expect(featureB.category).toEqual({ id: 'fooB', label: 'fooB' });
expect(featureA.order).toBe(123);
expect(featureB.order).toBe(2);
});
it('rejects overrides for unknown privileges', () => {
expect(() =>
registry.applyOverrides({ featureB: { privileges: { all: { disabled: true } } } })
).toThrowErrorMatchingInlineSnapshot(
`"Cannot override privilege \\"all\\" of feature \\"featureB\\" since \\"all\\" privilege is not registered."`
);
});
it('rejects overrides for `composedOf` referring to unknown feature', () => {
expect(() =>
registry.applyOverrides({
featureA: {
privileges: {
all: { composedOf: [{ feature: 'featureF', privileges: ['all'] }] },
},
},
})
).toThrowErrorMatchingInlineSnapshot(
`"Cannot compose privilege \\"all\\" of feature \\"featureA\\" with privileges of feature \\"featureF\\" since such feature is not registered."`
);
});
it('rejects overrides for `composedOf` referring to unknown feature privilege', () => {
expect(() =>
registry.applyOverrides({
featureA: {
privileges: {
all: { composedOf: [{ feature: 'featureB', privileges: ['none'] }] },
},
},
})
).toThrowErrorMatchingInlineSnapshot(
`"Cannot compose privilege \\"all\\" of feature \\"featureA\\" with privilege \\"none\\" of feature \\"featureB\\" since such privilege is not registered."`
);
});
it('can override `composedOf` referring to both feature and sub-feature privileges', () => {
registry.applyOverrides({
featureA: {
privileges: {
all: {
composedOf: [
{ feature: 'featureC', privileges: ['subFeatureCOne'] },
{ feature: 'featureD', privileges: ['all'] },
],
},
read: { composedOf: [{ feature: 'featureD', privileges: ['read'] }] },
},
},
});
registry.lockRegistration();
const [featureA] = registry.getAllKibanaFeatures();
expect(featureA.privileges).toEqual({
all: {
ui: [],
savedObject: { all: ['telemetry'], read: ['config', 'config-global', 'url'] },
composedOf: [
{ feature: 'featureC', privileges: ['subFeatureCOne'] },
{ feature: 'featureD', privileges: ['all'] },
],
},
read: {
ui: [],
savedObject: { all: [], read: ['config', 'config-global', 'telemetry', 'url'] },
composedOf: [{ feature: 'featureD', privileges: ['read'] }],
},
});
});
it('can override `composedOf` referring to a feature that requires custom RBAC', () => {
registry.applyOverrides({
featureA: {
privileges: {
all: { composedOf: [{ feature: 'featureE', privileges: ['all'] }] },
},
},
});
registry.lockRegistration();
const [featureA] = registry.getAllKibanaFeatures();
expect(featureA.privileges).toEqual({
all: {
ui: [],
savedObject: { all: ['telemetry'], read: ['config', 'config-global', 'url'] },
composedOf: [{ feature: 'featureE', privileges: ['all'] }],
},
read: {
ui: [],
savedObject: { all: [], read: ['config', 'config-global', 'telemetry', 'url'] },
},
});
});
it('rejects overrides for unknown sub-feature privileges', () => {
expect(() =>
registry.applyOverrides({
featureC: { subFeatures: { privileges: { all: { disabled: true } } } },
})
).toThrowErrorMatchingInlineSnapshot(
`"Cannot override sub-feature privilege \\"all\\" of feature \\"featureC\\" since \\"all\\" sub-feature privilege is not registered. Known sub-feature privileges are: subFeatureCOne."`
);
expect(() =>
registry.applyOverrides({
featureA: { subFeatures: { privileges: { subFeatureCOne: { disabled: true } } } },
})
).toThrowErrorMatchingInlineSnapshot(
`"Cannot override sub-feature privileges of feature \\"featureA\\" since it didn't register any."`
);
});
it('can override sub-feature privileges', () => {
registry.applyOverrides({
featureC: {
subFeatures: { privileges: { subFeatureCOne: { disabled: true, includeIn: 'none' } } },
},
});
registry.lockRegistration();
const [, , featureC] = registry.getAllKibanaFeatures();
expect(featureC.subFeatures).toEqual([
{
config: {
name: 'subFeatureC',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
disabled: true,
id: 'subFeatureCOne',
includeIn: 'none',
name: 'subFeature C One',
savedObject: { all: [], read: [] },
ui: [],
},
],
},
],
},
},
]);
});
});
});
describe('Elasticsearch Features', () => {

View file

@ -7,14 +7,17 @@
import { cloneDeep, uniq } from 'lodash';
import { ILicense } from '@kbn/licensing-plugin/server';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import {
KibanaFeatureConfig,
KibanaFeature,
FeatureKibanaPrivileges,
ElasticsearchFeatureConfig,
ElasticsearchFeature,
SubFeaturePrivilegeConfig,
} from '../common';
import { validateKibanaFeature, validateElasticsearchFeature } from './feature_schema';
import type { ConfigOverridesType } from './config';
export class FeatureRegistry {
private locked = false;
@ -61,6 +64,106 @@ export class FeatureRegistry {
this.esFeatures[feature.id] = featureCopy;
}
/**
* Updates definitions for the registered features using configuration overrides, if any.
*/
public applyOverrides(overrides: ConfigOverridesType) {
for (const [featureId, featureOverride] of Object.entries(overrides)) {
const feature = this.kibanaFeatures[featureId];
if (!feature) {
throw new Error(
`Cannot override feature "${featureId}" since feature with such ID is not registered.`
);
}
if (featureOverride.hidden) {
feature.hidden = featureOverride.hidden;
}
// Note that the name doesn't currently support localizable strings. We'll revisit this approach when i18n support
// becomes necessary.
if (featureOverride.name) {
feature.name = featureOverride.name;
}
if (featureOverride.category) {
feature.category = DEFAULT_APP_CATEGORIES[featureOverride.category];
}
if (featureOverride.order != null) {
feature.order = featureOverride.order;
}
if (featureOverride.privileges) {
for (const [privilegeId, privilegeOverride] of Object.entries(featureOverride.privileges)) {
const typedPrivilegeId = privilegeId as 'read' | 'all';
const targetPrivilege = feature.privileges?.[typedPrivilegeId];
if (!targetPrivilege) {
throw new Error(
`Cannot override privilege "${privilegeId}" of feature "${featureId}" since "${privilegeId}" privilege is not registered.`
);
}
for (const featureReference of privilegeOverride.composedOf ?? []) {
const referencedFeature = this.kibanaFeatures[featureReference.feature];
if (!referencedFeature) {
throw new Error(
`Cannot compose privilege "${privilegeId}" of feature "${featureId}" with privileges of feature "${featureReference.feature}" since such feature is not registered.`
);
}
// Collect all known feature and sub-feature privileges for the referenced feature.
const knownPrivileges = new Map(
Object.entries(referencedFeature.privileges ?? {}).concat(
collectSubFeaturesPrivileges(referencedFeature)
)
);
for (const privilegeReference of featureReference.privileges) {
const referencedPrivilege = knownPrivileges.get(privilegeReference);
if (!referencedPrivilege) {
throw new Error(
`Cannot compose privilege "${privilegeId}" of feature "${featureId}" with privilege "${privilegeReference}" of feature "${featureReference.feature}" since such privilege is not registered.`
);
}
}
}
// It's safe to assume that `feature.privileges` is defined here since we've checked it above.
feature.privileges![typedPrivilegeId] = { ...targetPrivilege, ...privilegeOverride };
}
}
if (featureOverride.subFeatures?.privileges) {
// Collect all known sub-feature privileges for the feature.
const knownPrivileges = new Map(collectSubFeaturesPrivileges(feature));
if (knownPrivileges.size === 0) {
throw new Error(
`Cannot override sub-feature privileges of feature "${featureId}" since it didn't register any.`
);
}
for (const [privilegeId, privilegeOverride] of Object.entries(
featureOverride.subFeatures.privileges
)) {
const targetPrivilege = knownPrivileges.get(privilegeId);
if (!targetPrivilege) {
throw new Error(
`Cannot override sub-feature privilege "${privilegeId}" of feature "${featureId}" since "${privilegeId}" sub-feature privilege is not registered. Known sub-feature privileges are: ${Array.from(
knownPrivileges.keys()
)}.`
);
}
targetPrivilege.disabled = privilegeOverride.disabled;
if (privilegeOverride.includeIn) {
targetPrivilege.includeIn = privilegeOverride.includeIn;
}
}
}
}
}
public getAllKibanaFeatures(license?: ILicense, ignoreLicense = false): KibanaFeature[] {
if (!this.locked) {
throw new Error('Cannot retrieve Kibana features while registration is still open');
@ -143,3 +246,15 @@ function applyAutomaticReadPrivilegeGrants(
}
});
}
function collectSubFeaturesPrivileges(feature: KibanaFeatureConfig) {
return (
feature.subFeatures?.flatMap((subFeature) =>
subFeature.privilegeGroups.flatMap(({ privileges }) =>
privileges.map(
(privilege) => [privilege.id, privilege] as [string, SubFeaturePrivilegeConfig]
)
)
) ?? []
);
}

View file

@ -187,6 +187,8 @@ const kibanaSubFeatureSchema = schema.object({
),
});
// NOTE: This schema intentionally omits the `composedOf` and `hidden` properties to discourage consumers from using
// them during feature registration. This is because these properties should only be set via configuration overrides.
const kibanaFeatureSchema = schema.object({
id: schema.string({
validate(value: string) {

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import { PluginInitializerContext } from '@kbn/core/server';
import type { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';
import { ConfigSchema } from './config';
// These exports are part of public Features plugin contract, any change in signature of exported
// functions or removal of exports should be considered as a breaking change. Ideally we should
@ -22,6 +24,7 @@ export type {
export { KibanaFeature, ElasticsearchFeature } from '../common';
export type { PluginSetupContract, PluginStartContract } from './plugin';
export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = { schema: ConfigSchema };
export const plugin = async (initializerContext: PluginInitializerContext) => {
const { FeaturesPlugin } = await import('./plugin');
return new FeaturesPlugin(initializerContext);

View file

@ -6,6 +6,7 @@
*/
import { coreMock, savedObjectsServiceMock } from '@kbn/core/server/mocks';
import { ConfigSchema } from './config';
import { FeaturesPlugin } from './plugin';
describe('Features Plugin', () => {
@ -120,4 +121,76 @@ describe('Features Plugin', () => {
expect(coreSetup.capabilities.registerProvider).toHaveBeenCalledTimes(1);
expect(coreSetup.capabilities.registerProvider).toHaveBeenCalledWith(expect.any(Function));
});
it('apply feature overrides', async () => {
const plugin = new FeaturesPlugin(
coreMock.createPluginInitializerContext(
ConfigSchema.validate(
{ overrides: { featureA: { name: 'overriddenFeatureName', order: 321 } } },
{ serverless: true }
)
)
);
const { registerKibanaFeature } = plugin.setup(coreSetup);
registerKibanaFeature({
id: 'featureA',
name: 'featureAName',
app: [],
category: { id: 'foo', label: 'foo' },
order: 123,
privileges: {
all: { savedObject: { all: ['one'], read: ['two'] }, ui: [] },
read: { savedObject: { all: ['three'], read: ['four'] }, ui: [] },
},
});
const { getKibanaFeatures } = plugin.start(coreStart);
expect(getKibanaFeatures().find((feature) => feature.id === 'featureA')).toMatchInlineSnapshot(`
KibanaFeature {
"config": Object {
"app": Array [],
"category": Object {
"id": "foo",
"label": "foo",
},
"id": "featureA",
"name": "overriddenFeatureName",
"order": 321,
"privileges": Object {
"all": Object {
"savedObject": Object {
"all": Array [
"one",
"telemetry",
],
"read": Array [
"two",
"config",
"config-global",
"url",
],
},
"ui": Array [],
},
"read": Object {
"savedObject": Object {
"all": Array [
"three",
],
"read": Array [
"four",
"config",
"config-global",
"telemetry",
"url",
],
},
"ui": Array [],
},
},
},
"subFeatures": Array [],
}
`);
});
});

View file

@ -16,6 +16,7 @@ import {
PluginInitializerContext,
Capabilities as UICapabilities,
} from '@kbn/core/server';
import { ConfigType } from './config';
import { FeatureRegistry } from './feature_registry';
import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features';
import { buildOSSFeatures } from './oss_features';
@ -127,6 +128,12 @@ export class FeaturesPlugin
public start(core: CoreStart): RecursiveReadonly<PluginStartContract> {
this.registerOssFeatures(core.savedObjects);
const { overrides } = this.initializerContext.config.get<ConfigType>();
if (overrides) {
this.featureRegistry.applyOverrides(overrides);
}
this.featureRegistry.lockRegistration();
this.capabilities = uiCapabilitiesForFeatures(

View file

@ -8,7 +8,7 @@
import * as Rx from 'rxjs';
import { map, take } from 'rxjs';
import type {
import {
AnalyticsServiceStart,
CoreSetup,
DocLinksServiceSetup,
@ -237,41 +237,6 @@ export class ReportingCore {
return exportTypes;
}
/**
* If xpack.reporting.roles.enabled === true, register Reporting as a feature
* that is controlled by user role names
*/
public registerFeature() {
const { features } = this.getPluginSetupDeps();
const deprecatedRoles = this.getDeprecatedAllowedRoles();
if (deprecatedRoles !== false) {
// refer to roles.allow configuration (deprecated path)
const allowedRoles = ['superuser', ...(deprecatedRoles ?? [])];
const privileges = allowedRoles.map((role) => ({
requiredClusterPrivileges: [],
requiredRoles: [role],
ui: [],
}));
// self-register as an elasticsearch feature (deprecated)
features.registerElasticsearchFeature({
id: 'reporting',
catalogue: ['reporting'],
management: {
insightsAndAlerting: ['reporting'],
},
privileges,
});
} else {
this.logger.debug(
`Reporting roles configuration is disabled. Please assign access to Reporting use Kibana feature controls for applications.`
);
// trigger application to register Reporting as a subfeature
features.enableReportingUiCapabilities();
}
}
/*
* Returns configurable server info
*/

View file

@ -0,0 +1,74 @@
/*
* 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 { DEFAULT_APP_CATEGORIES, type Logger } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import type { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server';
interface FeatureRegistrationOpts {
features: FeaturesPluginSetup;
deprecatedRoles: string[] | false;
isServerless: boolean;
logger: Logger;
}
/**
* If xpack.reporting.roles.enabled === true, register Reporting as a feature
* that is controlled by user role names. Also, for Serverless register a
* 'shell' Reporting Kibana feature.
*/
export function registerFeatures({
isServerless,
features,
deprecatedRoles,
logger,
}: FeatureRegistrationOpts) {
// Register a 'shell' feature specifically for Serverless. If granted, it will automatically provide access to
// reporting capabilities in other features, such as Discover, Dashboards, and Visualizations. On its own, this
// feature doesn't grant any additional privileges.
if (isServerless) {
features.registerKibanaFeature({
id: 'reporting',
name: i18n.translate('xpack.reporting.features.reportingFeatureName', {
defaultMessage: 'Reporting',
}),
category: DEFAULT_APP_CATEGORIES.management,
app: [],
privileges: {
all: { savedObject: { all: [], read: [] }, ui: [] },
// No read-only mode currently supported
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
},
});
}
if (deprecatedRoles !== false) {
// refer to roles.allow configuration (deprecated path)
const allowedRoles = ['superuser', ...(deprecatedRoles ?? [])];
const privileges = allowedRoles.map((role) => ({
requiredClusterPrivileges: [],
requiredRoles: [role],
ui: [],
}));
// self-register as an elasticsearch feature (deprecated)
features.registerElasticsearchFeature({
id: 'reporting',
catalogue: ['reporting'],
management: {
insightsAndAlerting: ['reporting'],
},
privileges,
});
} else {
logger.debug(
`Reporting roles configuration is disabled. Please assign access to Reporting use Kibana feature controls for applications.`
);
// trigger application to register Reporting as a subfeature
features.enableReportingUiCapabilities();
}
}

View file

@ -5,8 +5,15 @@
* 2.0.
*/
import type { CoreSetup, CoreStart, Logger } from '@kbn/core/server';
import {
CoreSetup,
CoreStart,
DEFAULT_APP_CATEGORIES,
Logger,
type PackageInfo,
} from '@kbn/core/server';
import { coreMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { featuresPluginMock } from '@kbn/features-plugin/server/mocks';
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
import { CSV_REPORT_TYPE, CSV_REPORT_TYPE_V2 } from '@kbn/reporting-export-types-csv-common';
@ -18,6 +25,7 @@ import { ReportingPlugin } from './plugin';
import { createMockPluginSetup, createMockPluginStart } from './test_helpers';
import type { ReportingSetupDeps } from './types';
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
import { PluginSetupContract as FeaturesPluginSetupContract } from '@kbn/features-plugin/server';
const sleep = (time: number) => new Promise((r) => setTimeout(r, time));
@ -30,6 +38,7 @@ describe('Reporting Plugin', () => {
let pluginStart: ReportingInternalStart;
let logger: jest.Mocked<Logger>;
let plugin: ReportingPlugin;
let featuresSetup: jest.Mocked<FeaturesPluginSetupContract>;
beforeEach(async () => {
jest.clearAllMocks();
@ -38,7 +47,10 @@ describe('Reporting Plugin', () => {
initContext = coreMock.createPluginInitializerContext(configSchema);
coreSetup = coreMock.createSetup(configSchema);
coreStart = coreMock.createStart();
pluginSetup = createMockPluginSetup({}) as unknown as ReportingSetupDeps;
featuresSetup = featuresPluginMock.createSetup();
pluginSetup = createMockPluginSetup({
features: featuresSetup,
}) as unknown as ReportingSetupDeps;
pluginStart = await createMockPluginStart(coreStart, configSchema);
logger = loggingSystemMock.createLogger();
@ -143,4 +155,33 @@ describe('Reporting Plugin', () => {
);
});
});
describe('features registration', () => {
it('does not register Kibana reporting feature in traditional build flavour', async () => {
plugin.setup(coreSetup, pluginSetup);
expect(featuresSetup.registerKibanaFeature).not.toHaveBeenCalled();
expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1);
});
it('registers Kibana reporting feature in serverless build flavour', async () => {
const serverlessInitContext = coreMock.createPluginInitializerContext(configSchema);
// Force type-cast to convert `ReadOnly<PackageInfo>` to mutable `PackageInfo`.
(serverlessInitContext.env.packageInfo as PackageInfo).buildFlavor = 'serverless';
plugin = new ReportingPlugin(serverlessInitContext);
plugin.setup(coreSetup, pluginSetup);
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1);
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
id: 'reporting',
name: 'Reporting',
category: DEFAULT_APP_CATEGORIES.management,
app: [],
privileges: {
all: { savedObject: { all: [], read: [] }, ui: [] },
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
},
});
expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -26,6 +26,7 @@ import type {
} from './types';
import { ReportingRequestHandlerContext } from './types';
import { registerReportingEventTypes, registerReportingUsageCollector } from './usage';
import { registerFeatures } from './features';
/*
* @internal
@ -79,8 +80,13 @@ export class ReportingPlugin
// async background setup
(async () => {
// Feature registration relies on config, so it cannot be setup before here.
reportingCore.registerFeature();
// Feature registration relies on config, depending on whether deprecated roles are enabled, so it cannot be setup before here.
registerFeatures({
features: plugins.features,
deprecatedRoles: reportingCore.getDeprecatedAllowedRoles(),
isServerless: this.initContext.env.packageInfo.buildFlavor === 'serverless',
logger: this.logger,
});
this.logger.debug('Setup complete');
})().catch((e) => {
this.logger.error(`Error in Reporting setup, reporting may not function properly`);

View file

@ -308,7 +308,7 @@ function useFeatures(
fatalErrors.add(err);
})
.then((retrievedFeatures) => {
setFeatures(retrievedFeatures);
setFeatures(retrievedFeatures?.filter((feature) => !feature.hidden) ?? null);
});
}, [fatalErrors, getFeatures]);

View file

@ -36,9 +36,7 @@ interface Props {
}
export const SubFeatureForm = (props: Props) => {
const groupsWithPrivileges = props.subFeature
.getPrivilegeGroups()
.filter((group) => group.privileges.length > 0);
const groupsWithPrivileges = props.subFeature.getPrivilegeGroups();
const getTooltip = () => {
if (!props.subFeature.privilegesTooltip) {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { SubFeatureConfig } from '@kbn/features-plugin/common';
import type { SubFeatureConfig, SubFeaturePrivilegeGroupConfig } from '@kbn/features-plugin/common';
import { SubFeature } from '@kbn/features-plugin/common';
import { SubFeaturePrivilege } from './sub_feature_privilege';
@ -14,6 +14,10 @@ import { SubFeaturePrivilegeGroup } from './sub_feature_privilege_group';
export class SecuredSubFeature extends SubFeature {
public readonly privileges: SubFeaturePrivilege[];
public readonly privilegesTooltip: string;
/**
* A list of the privilege groups that have at least one enabled privilege.
*/
private readonly nonEmptyPrivilegeGroups: SubFeaturePrivilegeGroupConfig[];
constructor(
config: SubFeatureConfig,
@ -23,14 +27,27 @@ export class SecuredSubFeature extends SubFeature {
this.privilegesTooltip = config.privilegesTooltip || '';
this.privileges = [];
for (const privilege of this.privilegeIterator()) {
this.privileges.push(privilege);
}
this.nonEmptyPrivilegeGroups = this.privilegeGroups.flatMap((group) => {
const filteredPrivileges = group.privileges.filter((privilege) => !privilege.disabled);
if (filteredPrivileges.length === 0) {
return [];
}
// If some privileges are disabled, we need to update the group to reflect the change.
return [
group.privileges.length === filteredPrivileges.length
? group
: ({ ...group, privileges: filteredPrivileges } as SubFeaturePrivilegeGroupConfig),
];
});
this.privileges = Array.from(this.privilegeIterator());
}
public getPrivilegeGroups() {
return this.privilegeGroups.map((pg) => new SubFeaturePrivilegeGroup(pg, this.actionMapping));
return this.nonEmptyPrivilegeGroups.map(
(pg) => new SubFeaturePrivilegeGroup(pg, this.actionMapping)
);
}
public *privilegeIterator({
@ -38,10 +55,13 @@ export class SecuredSubFeature extends SubFeature {
}: {
predicate?: (privilege: SubFeaturePrivilege, feature: SecuredSubFeature) => boolean;
} = {}): IterableIterator<SubFeaturePrivilege> {
for (const group of this.privilegeGroups) {
yield* group.privileges
.map((gp) => new SubFeaturePrivilege(gp, this.actionMapping[gp.id]))
.filter((privilege) => predicate(privilege, this));
for (const group of this.nonEmptyPrivilegeGroups) {
for (const gp of group.privileges) {
const privilege = new SubFeaturePrivilege(gp, this.actionMapping[gp.id]);
if (predicate(privilege, this)) {
yield privilege;
}
}
}
}

View file

@ -21,6 +21,10 @@ export class SubFeaturePrivilege extends KibanaPrivilege {
return this.subPrivilegeConfig.name;
}
public get disabled() {
return this.subPrivilegeConfig.disabled;
}
public get requireAllSpaces() {
return this.subPrivilegeConfig.requireAllSpaces ?? false;
}

View file

@ -185,6 +185,293 @@ describe('features', () => {
});
});
test('actions should respect `composedOf` specified at the privilege', () => {
const features: KibanaFeature[] = [
new KibanaFeature({
id: 'foo',
name: 'Foo KibanaFeature',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
savedObject: {
all: ['all-savedObject-all-1'],
read: ['all-savedObject-read-1'],
},
ui: ['all-ui-1'],
},
read: {
savedObject: {
all: ['read-savedObject-all-1'],
read: ['read-savedObject-read-1'],
},
ui: ['read-ui-1'],
},
},
}),
new KibanaFeature({
id: 'bar',
name: 'Bar KibanaFeature',
app: [],
category: { id: 'bar', label: 'bar' },
privileges: {
all: {
savedObject: {
all: ['all-savedObject-all-2'],
read: ['all-savedObject-read-2'],
},
ui: ['all-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['all'] }],
},
read: {
savedObject: {
all: ['read-savedObject-all-2'],
read: ['read-savedObject-read-2'],
},
ui: ['read-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['read'] }],
},
},
}),
];
const mockFeaturesPlugin = featuresPluginMock.createSetup();
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features);
const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic);
const expectedAllPrivileges = [
actions.login,
actions.savedObject.get('all-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-2', 'get'),
actions.savedObject.get('all-savedObject-all-2', 'find'),
actions.savedObject.get('all-savedObject-all-2', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-all-2', 'close_point_in_time'),
actions.savedObject.get('all-savedObject-all-2', 'create'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-2', 'update'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-2', 'delete'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('all-savedObject-read-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-2', 'get'),
actions.savedObject.get('all-savedObject-read-2', 'find'),
actions.savedObject.get('all-savedObject-read-2', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-read-2', 'close_point_in_time'),
actions.ui.get('bar', 'all-ui-2'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-1', 'get'),
actions.savedObject.get('all-savedObject-all-1', 'find'),
actions.savedObject.get('all-savedObject-all-1', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-all-1', 'close_point_in_time'),
actions.savedObject.get('all-savedObject-all-1', 'create'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-1', 'update'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-1', 'delete'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('all-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-1', 'get'),
actions.savedObject.get('all-savedObject-read-1', 'find'),
actions.savedObject.get('all-savedObject-read-1', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-read-1', 'close_point_in_time'),
actions.ui.get('foo', 'all-ui-1'),
];
const expectedReadPrivileges = [
actions.login,
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
actions.savedObject.get('read-savedObject-all-2', 'find'),
actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'),
actions.savedObject.get('read-savedObject-all-2', 'create'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-2', 'get'),
actions.savedObject.get('read-savedObject-read-2', 'find'),
actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'),
actions.ui.get('bar', 'read-ui-2'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-1', 'get'),
actions.savedObject.get('read-savedObject-all-1', 'find'),
actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'),
actions.savedObject.get('read-savedObject-all-1', 'create'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
actions.savedObject.get('read-savedObject-read-1', 'find'),
actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'),
actions.ui.get('foo', 'read-ui-1'),
];
const actual = privileges.get();
expect(actual).toHaveProperty('features.bar', {
all: [...expectedAllPrivileges],
read: [...expectedReadPrivileges],
minimal_all: [...expectedAllPrivileges],
minimal_read: [...expectedReadPrivileges],
});
});
test('actions should respect `composedOf` specified at the privilege even if the referenced feature is hidden', () => {
const features: KibanaFeature[] = [
new KibanaFeature({
hidden: true,
id: 'foo',
name: 'Foo KibanaFeature',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
savedObject: {
all: ['all-savedObject-all-1'],
read: ['all-savedObject-read-1'],
},
ui: ['all-ui-1'],
},
read: {
savedObject: {
all: ['read-savedObject-all-1'],
read: ['read-savedObject-read-1'],
},
ui: ['read-ui-1'],
},
},
}),
new KibanaFeature({
id: 'bar',
name: 'Bar KibanaFeature',
app: [],
category: { id: 'bar', label: 'bar' },
privileges: {
all: {
savedObject: {
all: ['all-savedObject-all-2'],
read: ['all-savedObject-read-2'],
},
ui: ['all-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['all'] }],
},
read: {
savedObject: {
all: ['read-savedObject-all-2'],
read: ['read-savedObject-read-2'],
},
ui: ['read-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['read'] }],
},
},
}),
];
const mockFeaturesPlugin = featuresPluginMock.createSetup();
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features);
const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic);
const expectedAllPrivileges = [
actions.login,
actions.savedObject.get('all-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-2', 'get'),
actions.savedObject.get('all-savedObject-all-2', 'find'),
actions.savedObject.get('all-savedObject-all-2', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-all-2', 'close_point_in_time'),
actions.savedObject.get('all-savedObject-all-2', 'create'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-2', 'update'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-2', 'delete'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('all-savedObject-read-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-2', 'get'),
actions.savedObject.get('all-savedObject-read-2', 'find'),
actions.savedObject.get('all-savedObject-read-2', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-read-2', 'close_point_in_time'),
actions.ui.get('bar', 'all-ui-2'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-1', 'get'),
actions.savedObject.get('all-savedObject-all-1', 'find'),
actions.savedObject.get('all-savedObject-all-1', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-all-1', 'close_point_in_time'),
actions.savedObject.get('all-savedObject-all-1', 'create'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-1', 'update'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-1', 'delete'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('all-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-1', 'get'),
actions.savedObject.get('all-savedObject-read-1', 'find'),
actions.savedObject.get('all-savedObject-read-1', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-read-1', 'close_point_in_time'),
actions.ui.get('foo', 'all-ui-1'),
];
const expectedReadPrivileges = [
actions.login,
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
actions.savedObject.get('read-savedObject-all-2', 'find'),
actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'),
actions.savedObject.get('read-savedObject-all-2', 'create'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-2', 'get'),
actions.savedObject.get('read-savedObject-read-2', 'find'),
actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'),
actions.ui.get('bar', 'read-ui-2'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-1', 'get'),
actions.savedObject.get('read-savedObject-all-1', 'find'),
actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'),
actions.savedObject.get('read-savedObject-all-1', 'create'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
actions.savedObject.get('read-savedObject-read-1', 'find'),
actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'),
actions.ui.get('foo', 'read-ui-1'),
];
const actual = privileges.get();
expect(actual).toHaveProperty('features.bar', {
all: [...expectedAllPrivileges],
read: [...expectedReadPrivileges],
minimal_all: [...expectedAllPrivileges],
minimal_read: [...expectedReadPrivileges],
});
});
test(`features with no privileges aren't listed`, () => {
const features: KibanaFeature[] = [
new KibanaFeature({
@ -203,6 +490,55 @@ describe('features', () => {
const actual = privileges.get();
expect(actual).not.toHaveProperty('features.foo');
});
test(`hidden features aren't listed`, () => {
const features: KibanaFeature[] = [
new KibanaFeature({
hidden: true,
id: 'foo',
name: 'Foo KibanaFeature',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
management: {
'all-management': ['all-management-1'],
},
catalogue: ['all-catalogue-1'],
savedObject: {
all: ['all-savedObject-all-1'],
read: ['all-savedObject-read-1'],
},
ui: ['all-ui-1'],
},
read: {
management: {
'read-management': ['read-management-1'],
},
catalogue: ['read-catalogue-1'],
savedObject: {
all: ['read-savedObject-all-1'],
read: ['read-savedObject-read-1'],
},
ui: ['read-ui-1'],
},
},
}),
];
const mockFeaturesPlugin = featuresPluginMock.createSetup();
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features);
const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic);
const actual = privileges.get();
expect(actual).not.toHaveProperty('features.foo');
const checkPredicate = (action: string) => action.includes('all-') || action.includes('read-');
expect(actual.global.all.some(checkPredicate)).toBe(false);
expect(actual.global.read.some(checkPredicate)).toBe(false);
expect(actual.space.all.some(checkPredicate)).toBe(false);
expect(actual.space.read.some(checkPredicate)).toBe(false);
});
});
// the `global` and `space` privileges behave very similarly, with the one exception being that
@ -377,6 +713,194 @@ describe('features', () => {
]);
});
test('actions defined in any feature privilege of a hidden but referenced feature are included in `all`, ignoring the excludeFromBasePrivileges property', () => {
const getFeatures = ({
excludeFromBasePrivileges,
}: {
excludeFromBasePrivileges: boolean;
}) => [
new KibanaFeature({
hidden: true,
excludeFromBasePrivileges,
id: 'foo',
name: 'Foo KibanaFeature',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
management: {
'all-management': ['all-management-1'],
},
catalogue: ['all-catalogue-1'],
savedObject: {
all: ['all-savedObject-all-1'],
read: ['all-savedObject-read-1'],
},
ui: ['all-ui-1'],
},
read: {
management: {
'read-management': ['read-management-1'],
},
catalogue: ['read-catalogue-1'],
savedObject: {
all: ['read-savedObject-all-1'],
read: ['read-savedObject-read-1'],
},
ui: ['read-ui-1'],
},
},
}),
new KibanaFeature({
id: 'bar',
name: 'Bar KibanaFeature',
app: [],
category: { id: 'bar', label: 'bar' },
privileges: {
all: {
management: {
'all-management': ['all-management-2'],
},
catalogue: ['all-catalogue-2'],
savedObject: {
all: ['all-savedObject-all-2'],
read: ['all-savedObject-read-2'],
},
ui: ['all-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['all'] }],
},
read: {
management: {
'read-management': ['read-management-2'],
},
catalogue: ['read-catalogue-2'],
savedObject: {
all: ['read-savedObject-all-2'],
read: ['read-savedObject-read-2'],
},
ui: ['read-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['read'] }],
},
},
}),
];
const expectedActions = [
actions.login,
...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []),
...(expectGetFeatures ? [actions.api.get('features')] : []),
...(expectGetFeatures ? [actions.api.get('taskManager')] : []),
...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []),
...(expectManageSpaces
? [
actions.space.manage,
actions.ui.get('spaces', 'manage'),
actions.ui.get('management', 'kibana', 'spaces'),
actions.ui.get('catalogue', 'spaces'),
]
: []),
...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []),
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'save')] : []),
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []),
actions.ui.get('catalogue', 'all-catalogue-2'),
actions.ui.get('management', 'all-management', 'all-management-2'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-2', 'get'),
actions.savedObject.get('all-savedObject-all-2', 'find'),
actions.savedObject.get('all-savedObject-all-2', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-all-2', 'close_point_in_time'),
actions.savedObject.get('all-savedObject-all-2', 'create'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-2', 'update'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-2', 'delete'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('all-savedObject-read-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-2', 'get'),
actions.savedObject.get('all-savedObject-read-2', 'find'),
actions.savedObject.get('all-savedObject-read-2', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-read-2', 'close_point_in_time'),
actions.ui.get('bar', 'all-ui-2'),
actions.ui.get('catalogue', 'read-catalogue-2'),
actions.ui.get('management', 'read-management', 'read-management-2'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
actions.savedObject.get('read-savedObject-all-2', 'find'),
actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'),
actions.savedObject.get('read-savedObject-all-2', 'create'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-2', 'get'),
actions.savedObject.get('read-savedObject-read-2', 'find'),
actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'),
actions.ui.get('bar', 'read-ui-2'),
actions.ui.get('catalogue', 'all-catalogue-1'),
actions.ui.get('management', 'all-management', 'all-management-1'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-1', 'get'),
actions.savedObject.get('all-savedObject-all-1', 'find'),
actions.savedObject.get('all-savedObject-all-1', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-all-1', 'close_point_in_time'),
actions.savedObject.get('all-savedObject-all-1', 'create'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-1', 'update'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-1', 'delete'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('all-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-1', 'get'),
actions.savedObject.get('all-savedObject-read-1', 'find'),
actions.savedObject.get('all-savedObject-read-1', 'open_point_in_time'),
actions.savedObject.get('all-savedObject-read-1', 'close_point_in_time'),
actions.ui.get('foo', 'all-ui-1'),
actions.ui.get('catalogue', 'read-catalogue-1'),
actions.ui.get('management', 'read-management', 'read-management-1'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-1', 'get'),
actions.savedObject.get('read-savedObject-all-1', 'find'),
actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'),
actions.savedObject.get('read-savedObject-all-1', 'create'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
actions.savedObject.get('read-savedObject-read-1', 'find'),
actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'),
actions.ui.get('foo', 'read-ui-1'),
];
const mockFeaturesPlugin = featuresPluginMock.createSetup();
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(
getFeatures({ excludeFromBasePrivileges: false })
);
expect(
privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic).get()
).toHaveProperty(`${group}.all`, expectedActions);
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(
getFeatures({ excludeFromBasePrivileges: true })
);
expect(
privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic).get()
).toHaveProperty(`${group}.all`, expectedActions);
});
test('actions defined in a feature privilege with name `read` are included in `read`', () => {
const features: KibanaFeature[] = [
new KibanaFeature({
@ -467,6 +991,141 @@ describe('features', () => {
]);
});
test('actions defined in a feature privilege with name `read` of a hidden but referenced feature are included in `read`, ignoring the excludeFromBasePrivileges property', () => {
const getFeatures = ({
excludeFromBasePrivileges,
}: {
excludeFromBasePrivileges: boolean;
}) => [
new KibanaFeature({
hidden: true,
excludeFromBasePrivileges,
id: 'foo',
name: 'Foo KibanaFeature',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
management: {
'all-management': ['all-management-1'],
},
catalogue: ['all-catalogue-1'],
savedObject: {
all: ['all-savedObject-all-1'],
read: ['all-savedObject-read-1'],
},
ui: ['all-ui-1'],
},
read: {
management: {
'read-management': ['read-management-1'],
},
catalogue: ['read-catalogue-1'],
savedObject: {
all: ['read-savedObject-all-1'],
read: ['read-savedObject-read-1'],
},
ui: ['read-ui-1'],
},
},
}),
new KibanaFeature({
id: 'bar',
name: 'Bar KibanaFeature',
app: [],
category: { id: 'bar', label: 'bar' },
privileges: {
all: {
management: {
'all-management': ['all-management-2'],
},
catalogue: ['all-catalogue-2'],
savedObject: {
all: ['all-savedObject-all-2'],
read: ['all-savedObject-read-2'],
},
ui: ['all-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['all'] }],
},
read: {
management: {
'read-management': ['read-management-2'],
},
catalogue: ['read-catalogue-2'],
savedObject: {
all: ['read-savedObject-all-2'],
read: ['read-savedObject-read-2'],
},
ui: ['read-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['read'] }],
},
},
}),
];
const expectedActions = [
actions.login,
...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []),
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []),
actions.ui.get('catalogue', 'read-catalogue-2'),
actions.ui.get('management', 'read-management', 'read-management-2'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
actions.savedObject.get('read-savedObject-all-2', 'find'),
actions.savedObject.get('read-savedObject-all-2', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-all-2', 'close_point_in_time'),
actions.savedObject.get('read-savedObject-all-2', 'create'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-2', 'get'),
actions.savedObject.get('read-savedObject-read-2', 'find'),
actions.savedObject.get('read-savedObject-read-2', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-read-2', 'close_point_in_time'),
actions.ui.get('bar', 'read-ui-2'),
actions.ui.get('catalogue', 'read-catalogue-1'),
actions.ui.get('management', 'read-management', 'read-management-1'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-1', 'get'),
actions.savedObject.get('read-savedObject-all-1', 'find'),
actions.savedObject.get('read-savedObject-all-1', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-all-1', 'close_point_in_time'),
actions.savedObject.get('read-savedObject-all-1', 'create'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
actions.savedObject.get('read-savedObject-read-1', 'find'),
actions.savedObject.get('read-savedObject-read-1', 'open_point_in_time'),
actions.savedObject.get('read-savedObject-read-1', 'close_point_in_time'),
actions.ui.get('foo', 'read-ui-1'),
];
const mockFeaturesPlugin = featuresPluginMock.createSetup();
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(
getFeatures({ excludeFromBasePrivileges: false })
);
expect(
privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic).get()
).toHaveProperty(`${group}.read`, expectedActions);
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(
getFeatures({ excludeFromBasePrivileges: true })
);
expect(
privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic).get()
).toHaveProperty(`${group}.read`, expectedActions);
});
test('actions defined in a reserved privilege are not included in `all` or `read`', () => {
const features: KibanaFeature[] = [
new KibanaFeature({
@ -596,6 +1255,104 @@ describe('features', () => {
]);
});
test('actions defined via `composedOf` in a feature with excludeFromBasePrivileges are not included in `all` or `read', () => {
const features: KibanaFeature[] = [
new KibanaFeature({
hidden: true,
id: 'foo',
name: 'Foo KibanaFeature',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
management: {
'all-management': ['all-management-1'],
},
catalogue: ['all-catalogue-1'],
savedObject: {
all: ['all-savedObject-all-1'],
read: ['all-savedObject-read-1'],
},
ui: ['all-ui-1'],
},
read: {
management: {
'read-management': ['read-management-1'],
},
catalogue: ['read-catalogue-1'],
savedObject: {
all: ['read-savedObject-all-1'],
read: ['read-savedObject-read-1'],
},
ui: ['read-ui-1'],
},
},
}),
new KibanaFeature({
excludeFromBasePrivileges: true,
id: 'bar',
name: 'Bar KibanaFeature',
app: [],
category: { id: 'bar', label: 'bar' },
privileges: {
all: {
management: {
'all-management': ['all-management-2'],
},
catalogue: ['all-catalogue-2'],
savedObject: {
all: ['all-savedObject-all-2'],
read: ['all-savedObject-read-2'],
},
ui: ['all-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['all'] }],
},
read: {
management: {
'read-management': ['read-management-2'],
},
catalogue: ['read-catalogue-2'],
savedObject: {
all: ['read-savedObject-all-2'],
read: ['read-savedObject-read-2'],
},
ui: ['read-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['read'] }],
},
},
}),
];
const mockFeaturesPlugin = featuresPluginMock.createSetup();
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features);
const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic);
const actual = privileges.get();
expect(actual).toHaveProperty(`${group}.all`, [
actions.login,
...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []),
...(expectGetFeatures ? [actions.api.get('features')] : []),
...(expectGetFeatures ? [actions.api.get('taskManager')] : []),
...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []),
...(expectManageSpaces
? [
actions.space.manage,
actions.ui.get('spaces', 'manage'),
actions.ui.get('management', 'kibana', 'spaces'),
actions.ui.get('catalogue', 'spaces'),
]
: []),
...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []),
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'save')] : []),
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []),
]);
expect(actual).toHaveProperty(`${group}.read`, [
actions.login,
...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []),
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []),
]);
});
test('actions defined in an individual feature privilege with excludeFromBasePrivileges are not included in `all` or `read`', () => {
const features: KibanaFeature[] = [
new KibanaFeature({
@ -665,6 +1422,105 @@ describe('features', () => {
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []),
]);
});
test('actions defined via `composedOf` in an individual feature privilege with excludeFromBasePrivileges are not included in `all` or `read`', () => {
const features: KibanaFeature[] = [
new KibanaFeature({
hidden: true,
id: 'foo',
name: 'Foo KibanaFeature',
app: [],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
management: {
'all-management': ['all-management-1'],
},
catalogue: ['all-catalogue-1'],
savedObject: {
all: ['all-savedObject-all-1'],
read: ['all-savedObject-read-1'],
},
ui: ['all-ui-1'],
},
read: {
management: {
'read-management': ['read-management-1'],
},
catalogue: ['read-catalogue-1'],
savedObject: {
all: ['read-savedObject-all-1'],
read: ['read-savedObject-read-1'],
},
ui: ['read-ui-1'],
},
},
}),
new KibanaFeature({
id: 'bar',
name: 'Bar KibanaFeature',
app: [],
category: { id: 'bar', label: 'bar' },
privileges: {
all: {
excludeFromBasePrivileges: true,
management: {
'all-management': ['all-management-2'],
},
catalogue: ['all-catalogue-2'],
savedObject: {
all: ['all-savedObject-all-2'],
read: ['all-savedObject-read-2'],
},
ui: ['all-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['all'] }],
},
read: {
excludeFromBasePrivileges: true,
management: {
'read-management': ['read-management-2'],
},
catalogue: ['read-catalogue-2'],
savedObject: {
all: ['read-savedObject-all-2'],
read: ['read-savedObject-read-2'],
},
ui: ['read-ui-2'],
composedOf: [{ feature: 'foo', privileges: ['read'] }],
},
},
}),
];
const mockFeaturesPlugin = featuresPluginMock.createSetup();
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features);
const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic);
const actual = privileges.get();
expect(actual).toHaveProperty(`${group}.all`, [
actions.login,
...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []),
...(expectGetFeatures ? [actions.api.get('features')] : []),
...(expectGetFeatures ? [actions.api.get('taskManager')] : []),
...(expectGetFeatures ? [actions.api.get('manageSpaces')] : []),
...(expectManageSpaces
? [
actions.space.manage,
actions.ui.get('spaces', 'manage'),
actions.ui.get('management', 'kibana', 'spaces'),
actions.ui.get('catalogue', 'spaces'),
]
: []),
...(expectEnterpriseSearch ? [actions.ui.get('enterpriseSearch', 'all')] : []),
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'save')] : []),
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []),
]);
expect(actual).toHaveProperty(`${group}.read`, [
actions.login,
...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []),
...(expectGlobalSettings ? [actions.ui.get('globalSettings', 'show')] : []),
]);
});
});
}
);

View file

@ -7,6 +7,10 @@
import { uniq } from 'lodash';
import type {
FeatureKibanaPrivileges,
FeatureKibanaPrivilegesReference,
} from '@kbn/features-plugin/common';
import type {
PluginSetupContract as FeaturesPluginSetup,
KibanaFeature,
@ -41,6 +45,10 @@ export function privilegesFactory(
const readActionsSet = new Set<string>();
basePrivilegeFeatures.forEach((feature) => {
if (feature.hidden) {
return;
}
for (const { privilegeId, privilege } of featuresService.featurePrivilegeIterator(feature, {
augmentWithSubFeaturePrivileges: true,
licenseHasAtLeast,
@ -56,9 +64,31 @@ export function privilegesFactory(
}
});
const allActions = [...allActionsSet];
const readActions = [...readActionsSet];
// Remember privilege as composable to update it later, once actions for all referenced privileges are also
// calculated and registered.
const composableFeaturePrivileges: Array<{
featureId: string;
privilegeId: string;
excludeFromBasePrivileges?: boolean;
composedOf: readonly FeatureKibanaPrivilegesReference[];
}> = [];
const tryStoreComposableFeature = (
feature: KibanaFeature,
privilegeId: string,
privilege: FeatureKibanaPrivileges
) => {
if (privilege.composedOf) {
composableFeaturePrivileges.push({
featureId: feature.id,
privilegeId,
composedOf: privilege.composedOf,
excludeFromBasePrivileges:
feature.excludeFromBasePrivileges || privilege.excludeFromBasePrivileges,
});
}
};
const hiddenFeatures = new Set<string>();
const featurePrivileges: Record<string, Record<string, string[]>> = {};
for (const feature of features) {
featurePrivileges[feature.id] = {};
@ -66,20 +96,26 @@ export function privilegesFactory(
augmentWithSubFeaturePrivileges: true,
licenseHasAtLeast,
})) {
featurePrivileges[feature.id][featurePrivilege.privilegeId] = [
const fullPrivilegeId = featurePrivilege.privilegeId;
featurePrivileges[feature.id][fullPrivilegeId] = [
actions.login,
...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)),
];
tryStoreComposableFeature(feature, fullPrivilegeId, featurePrivilege.privilege);
}
for (const featurePrivilege of featuresService.featurePrivilegeIterator(feature, {
augmentWithSubFeaturePrivileges: false,
licenseHasAtLeast,
})) {
featurePrivileges[feature.id][`minimal_${featurePrivilege.privilegeId}`] = [
const minimalPrivilegeId = `minimal_${featurePrivilege.privilegeId}`;
featurePrivileges[feature.id][minimalPrivilegeId] = [
actions.login,
...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)),
];
tryStoreComposableFeature(feature, minimalPrivilegeId, featurePrivilege.privilege);
}
if (
@ -97,10 +133,53 @@ export function privilegesFactory(
}
}
if (Object.keys(featurePrivileges[feature.id]).length === 0) {
delete featurePrivileges[feature.id];
if (feature.hidden || Object.keys(featurePrivileges[feature.id]).length === 0) {
hiddenFeatures.add(feature.id);
}
}
// Update composable feature privileges to include and deduplicate actions from the referenced privileges.
// Note that we should do it _before_ removing hidden features. Also, currently, feature privilege composition
// doesn't respect the minimum license level required by the feature whose privileges are being included in
// 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]
)
);
featurePrivileges[composableFeature.featureId][composableFeature.privilegeId] = [
...new Set(
featurePrivileges[composableFeature.featureId][composableFeature.privilegeId].concat(
composedActions
)
),
];
if (!composableFeature.excludeFromBasePrivileges) {
for (const action of composedActions) {
// Login action is special since it's added explicitly for feature and base privileges.
if (action === actions.login) {
continue;
}
allActionsSet.add(action);
if (composableFeature.privilegeId === 'read') {
readActionsSet.add(action);
}
}
}
}
// Remove hidden features to avoid registering standalone privileges for them.
for (const hiddenFeatureId of hiddenFeatures) {
delete featurePrivileges[hiddenFeatureId];
}
const allActions = [...allActionsSet];
const readActions = [...readActionsSet];
return {
features: featurePrivileges,
global: {

View file

@ -5,7 +5,13 @@
* 2.0.
*/
import { transformPrivilegesToElasticsearchPrivileges } from './role_utils';
import { KibanaFeature } from '@kbn/features-plugin/common';
import { getKibanaRoleSchema } from '@kbn/security-plugin-types-server';
import {
transformPrivilegesToElasticsearchPrivileges,
validateKibanaPrivileges,
} from './role_utils';
import { ALL_SPACES_ID } from '../../common/constants';
describe('transformPrivilegesToElasticsearchPrivileges', () => {
@ -24,3 +30,163 @@ describe('transformPrivilegesToElasticsearchPrivileges', () => {
]);
});
});
describe('validateKibanaPrivileges', () => {
test('properly validates sub-feature privileges', () => {
const existingKibanaFeatures = [
new KibanaFeature({
id: 'feature1',
name: 'Feature1',
app: ['app1'],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
app: ['foo'],
catalogue: ['foo'],
savedObject: { all: ['foo'], read: [] },
ui: ['save', 'show'],
},
read: {
app: ['foo'],
catalogue: ['foo'],
savedObject: { all: [], read: ['foo'] },
ui: ['show'],
},
},
}),
new KibanaFeature({
id: 'feature2',
name: 'Feature2',
app: ['app2'],
category: { id: 'foo', label: 'foo' },
privileges: {
all: {
app: ['foo'],
catalogue: ['foo'],
savedObject: { all: ['foo'], read: [] },
ui: ['save', 'show'],
},
read: {
app: ['foo'],
catalogue: ['foo'],
savedObject: { all: [], read: ['foo'] },
ui: ['show'],
},
},
subFeatures: [
{
name: 'subFeature1',
privilegeGroups: [
{
groupType: 'independent',
privileges: [
{
id: 'subFeaturePrivilege1',
name: 'SubFeaturePrivilege1',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: [],
},
{
disabled: true,
id: 'subFeaturePrivilege2',
name: 'SubFeaturePrivilege2',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: [],
},
{
disabled: true,
id: 'subFeaturePrivilege3',
name: 'SubFeaturePrivilege3',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: [],
},
],
},
],
},
{
name: 'subFeature2',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
disabled: true,
id: 'subFeaturePrivilege4',
name: 'SubFeaturePrivilege4',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: [],
},
{
id: 'subFeaturePrivilege5',
name: 'SubFeaturePrivilege5',
includeIn: 'all',
savedObject: { all: [], read: [] },
ui: [],
},
],
},
],
},
],
}),
];
const { validationErrors: emptyErrors } = validateKibanaPrivileges(
existingKibanaFeatures,
getKibanaRoleSchema(() => ({ global: [], space: [] })).validate([
{
feature: {
feature2: ['all', 'subFeaturePrivilege1', 'subFeaturePrivilege5'],
},
},
])
);
expect(emptyErrors).toHaveLength(0);
const { validationErrors: nonEmptyErrors1 } = validateKibanaPrivileges(
existingKibanaFeatures,
getKibanaRoleSchema(() => ({ global: [], space: [] })).validate([
{
feature: {
feature2: [
'all',
'subFeaturePrivilege1',
'subFeaturePrivilege2',
'subFeaturePrivilege5',
],
},
},
])
);
expect(nonEmptyErrors1).toEqual([
'Feature [feature2] does not support specified sub-feature privileges [subFeaturePrivilege2].',
]);
const { validationErrors: nonEmptyErrors2 } = validateKibanaPrivileges(
existingKibanaFeatures,
getKibanaRoleSchema(() => ({ global: [], space: [] })).validate([
{
feature: {
feature2: [
'all',
'subFeaturePrivilege1',
'subFeaturePrivilege2',
'subFeaturePrivilege3',
'subFeaturePrivilege4',
'subFeaturePrivilege5',
],
},
},
])
);
expect(nonEmptyErrors2).toEqual([
'Feature [feature2] does not support specified sub-feature privileges [subFeaturePrivilege2, subFeaturePrivilege3].',
'Feature [feature2] does not support specified sub-feature privileges [subFeaturePrivilege4].',
]);
});
});

View file

@ -69,12 +69,12 @@ export const validateKibanaPrivileges = (
const validationErrors = kibanaPrivileges.flatMap((priv) => {
const forAllSpaces = priv.spaces.includes(ALL_SPACES_ID);
return Object.entries(priv.feature ?? {}).flatMap(([featureId, feature]) => {
return Object.entries(priv.feature ?? {}).flatMap(([featureId, featurePrivileges]) => {
const errors: string[] = [];
const kibanaFeature = kibanaFeatures.find((f) => f.id === featureId);
const kibanaFeature = kibanaFeatures.find((f) => f.id === featureId && !f.hidden);
if (!kibanaFeature) return errors;
if (feature.includes('all')) {
if (featurePrivileges.includes('all')) {
if (kibanaFeature.privileges?.all.disabled) {
errors.push(`Feature [${featureId}] does not support privilege [all].`);
}
@ -88,7 +88,7 @@ export const validateKibanaPrivileges = (
}
}
if (feature.includes('read')) {
if (featurePrivileges.includes('read')) {
if (kibanaFeature.privileges?.read.disabled) {
errors.push(`Feature [${featureId}] does not support privilege [read].`);
}
@ -103,12 +103,25 @@ export const validateKibanaPrivileges = (
}
kibanaFeature.subFeatures.forEach((subFeature) => {
if (
// Check if the definition includes any sub-feature privileges.
const subFeaturePrivileges = subFeature.privilegeGroups.flatMap((group) =>
group.privileges.filter((privilege) => featurePrivileges.includes(privilege.id))
);
// If the definition includes any disabled sub-feature privileges, return an error.
const disabledSubFeaturePrivileges = subFeaturePrivileges.filter(
(privilege) => privilege.disabled
);
if (disabledSubFeaturePrivileges.length > 0) {
errors.push(
`Feature [${featureId}] does not support specified sub-feature privileges [${disabledSubFeaturePrivileges
.map((privilege) => privilege.id)
.join(', ')}].`
);
} else if (
subFeature.requireAllSpaces &&
!forAllSpaces &&
subFeature.privilegeGroups.some((group) =>
group.privileges.some((privilege) => feature.includes(privilege.id))
)
subFeaturePrivileges.length > 0
) {
errors.push(
`Sub-feature privilege [${kibanaFeature.name} - ${

View file

@ -6,11 +6,25 @@
*/
import expect from 'expect';
import { KibanaFeatureConfig, SubFeaturePrivilegeConfig } from '@kbn/features-plugin/common';
import { FtrProviderContext } from '../../../ftr_provider_context';
function collectSubFeaturesPrivileges(feature: KibanaFeatureConfig) {
return new Map(
feature.subFeatures?.flatMap((subFeature) =>
subFeature.privilegeGroups.flatMap(({ privileges }) =>
privileges.map(
(privilege) => [privilege.id, privilege] as [string, SubFeaturePrivilegeConfig]
)
)
) ?? []
);
}
export default function ({ getService }: FtrProviderContext) {
const svlCommonApi = getService('svlCommonApi');
const supertest = getService('supertest');
const log = getService('log');
describe('security/authorization', function () {
describe('route access', () => {
@ -76,5 +90,39 @@ export default function ({ getService }: FtrProviderContext) {
});
});
});
describe('available features', () => {
const svlUserManager = getService('svlUserManager');
const supertestWithoutAuth = getService('supertestWithoutAuth');
let adminCredentials: { Cookie: string };
before(async () => {
// get auth header for Viewer role
adminCredentials = await svlUserManager.getApiCredentialsForRole('admin');
});
it('all Dashboard and Discover sub-feature privileges are disabled', async () => {
const { body } = await supertestWithoutAuth
.get('/api/features')
.set(svlCommonApi.getInternalRequestHeader())
.set(adminCredentials)
.expect(200);
// We should make sure that neither Discover nor Dashboard displays any sub-feature privileges in Serverless.
// If any of these features adds a new sub-feature privilege we should make an explicit decision whether it
// should be displayed in Serverless.
const features = body as KibanaFeatureConfig[];
for (const featureId of ['discover', 'dashboard']) {
const feature = features.find((f) => f.id === featureId)!;
const subFeaturesPrivileges = collectSubFeaturesPrivileges(feature);
for (const privilege of subFeaturesPrivileges.values()) {
log.debug(
`Verifying that ${privilege.id} sub-feature privilege of ${featureId} feature is disabled.`
);
expect(privilege.disabled).toBe(true);
}
}
});
});
});
}

View file

@ -11,6 +11,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('Serverless observability API - feature flags', function () {
loadTestFile(require.resolve('./custom_threshold_rule'));
loadTestFile(require.resolve('./infra'));
loadTestFile(require.resolve('./platform_security'));
loadTestFile(require.resolve('../common/platform_security/roles_routes_feature_flag.ts'));
});
}

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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Platform security APIs', function () {
loadTestFile(require.resolve('./authorization'));
});
}

View file

@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Serverless search API - feature flags', function () {
loadTestFile(require.resolve('./platform_security'));
loadTestFile(require.resolve('../common/platform_security/roles_routes_feature_flag.ts'));
});
}

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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Platform security APIs', function () {
loadTestFile(require.resolve('./authorization'));
});
}

View file

@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Serverless security API - feature flags', function () {
loadTestFile(require.resolve('./platform_security'));
loadTestFile(require.resolve('../common/platform_security/roles_routes_feature_flag.ts'));
});
}

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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Platform security APIs', function () {
loadTestFile(require.resolve('./authorization'));
});
}

View file

@ -101,6 +101,7 @@
"@kbn/dataset-quality-plugin",
"@kbn/alerting-comparators",
"@kbn/search-types",
"@kbn/reporting-server"
"@kbn/reporting-server",
"@kbn/features-plugin"
]
}