[Security Solution][Serverless] AppFeatures improvements (#158935)

## Summary

issue: https://github.com/elastic/kibana/issues/158810
follow-up of: https://github.com/elastic/kibana/pull/158179

Improves the Security AppFeatures architecture:
- SubFeatures now preserve always the same order in the Security Kibana
config, despite the order of processing of enabled appFeatures.


![Security_sub_features](3fefa80d-dec6-4336-92db-66e43970fefc)

- Change the `productTypes` config format
- Update `getProductAppFeatures` to:
  - process the new `productTypes` format.
- include _essentials_ tiers PLIs inside _complete_ tiers automatically.
- AppFeatures module now receives an array of PLIs instead of an object
- AppFeatures config now uses only SubFeature IDS instead of
`subActions` config objects directly
- Upselling components updated and `useProductTypeByPLI` implemented to
display the Product Type required
This commit is contained in:
Sergi Massaneda 2023-06-02 20:18:10 +02:00 committed by GitHub
parent e2e03cac3b
commit 0fe67b2c04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 807 additions and 478 deletions

View file

@ -8,7 +8,7 @@ xpack.uptime.enabled: false
## Enable the Serverless Security plugin
xpack.serverless.security.enabled: true
xpack.serverless.security.productLineIds: ['securityComplete']
xpack.serverless.security.productTypes: [{ product_line: 'security', product_tier: 'complete' }]
## Set the home route
uiSettings.overrides.defaultRoute: /app/security/get_started

View file

@ -119,7 +119,7 @@ pageLoadAssetSize:
serverless: 16573
serverlessObservability: 30000
serverlessSearch: 30000
serverlessSecurity: 41807
serverlessSecurity: 66019
sessionView: 77750
share: 71239
snapshotRestore: 79032

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import { AppFeatureKey, AppFeatureKeys } from '@kbn/security-solution-plugin/common';
import { ALL_APP_FEATURE_KEYS } from '@kbn/security-solution-plugin/common';
export const DEFAULT_APP_FEATURES: AppFeatureKeys = {
[AppFeatureKey.advancedInsights]: true,
[AppFeatureKey.casesConnectors]: true,
};
// Just copying all feature keys for now.
// We may need a different set of keys in the future if we create serverless-specific appFeatures
export const DEFAULT_APP_FEATURES = [...ALL_APP_FEATURE_KEYS];

View file

@ -12,7 +12,7 @@ export { APP_UI_ID, APP_ID, CASES_FEATURE_ID, SERVER_APP_ID, SecurityPageName }
export { ELASTIC_SECURITY_RULE_ID } from './detection_engine/constants';
export { allowedExperimentalValues, type ExperimentalFeatures } from './experimental_features';
export type { AppFeatureKeys } from './types/app_features';
export { AppFeatureKey } from './types/app_features';
export { AppFeatureKey, ALL_APP_FEATURE_KEYS } from './types/app_features';
// Careful of exporting anything from this file as any file(s) you export here will cause your page bundle size to increase.
// If you're using functions/types/etc... internally it's best to import directly from their paths than expose the functions/types/etc... here.

View file

@ -20,10 +20,9 @@ export enum AppFeatureCasesKey {
}
// Merges the two enums.
export type AppFeatureKey = AppFeatureSecurityKey | AppFeatureCasesKey;
export type AppFeatureKeys = AppFeatureKey[];
// We need to merge the value and the type and export both to replicate how enum works.
export const AppFeatureKey = { ...AppFeatureSecurityKey, ...AppFeatureCasesKey };
export type AppFeatureKey = AppFeatureSecurityKey | AppFeatureCasesKey;
type AppFeatureSecurityKeys = { [key in AppFeatureSecurityKey]: boolean };
type AppFeatureCasesKeys = { [key in AppFeatureCasesKey]: boolean };
export type AppFeatureKeys = AppFeatureSecurityKeys & AppFeatureCasesKeys;
export const ALL_APP_FEATURE_KEYS = Object.freeze(Object.values(AppFeatureKey));

View file

@ -50,15 +50,28 @@ const CASES_APP_FEATURE_CONFIG = {
jest.mock('./security_kibana_features', () => {
return {
getSecurityBaseKibanaFeature: jest.fn().mockReturnValue(SECURITY_BASE_CONFIG),
getSecurityAppFeaturesConfig: jest.fn().mockReturnValue(SECURITY_APP_FEATURE_CONFIG),
getSecurityBaseKibanaFeature: jest.fn(() => SECURITY_BASE_CONFIG),
getSecurityBaseKibanaSubFeatureIds: jest.fn(() => ['subFeature1']),
getSecurityAppFeaturesConfig: jest.fn(() => SECURITY_APP_FEATURE_CONFIG),
};
});
jest.mock('./security_kibana_sub_features', () => {
return {
securitySubFeaturesMap: new Map([['subFeature1', { baz: 'baz' }]]),
};
});
jest.mock('./security_cases_kibana_features', () => {
return {
getCasesBaseKibanaFeature: jest.fn().mockReturnValue(CASES_BASE_CONFIG),
getCasesAppFeaturesConfig: jest.fn().mockReturnValue(CASES_APP_FEATURE_CONFIG),
getCasesBaseKibanaFeature: jest.fn(() => CASES_BASE_CONFIG),
getCasesBaseKibanaSubFeatureIds: jest.fn(() => ['subFeature1']),
getCasesAppFeaturesConfig: jest.fn(() => CASES_APP_FEATURE_CONFIG),
};
});
jest.mock('./security_cases_kibana_sub_features', () => {
return {
casesSubFeaturesMap: new Map([['subFeature1', { baz: 'baz' }]]),
};
});
@ -69,9 +82,7 @@ describe('AppFeatures', () => {
getKibanaFeatures: jest.fn(),
} as unknown as PluginSetupContract;
const appFeatureKeys = {
'test-base-feature': true,
} as unknown as AppFeatureKeys;
const appFeatureKeys = ['test-base-feature'] as unknown as AppFeatureKeys;
const appFeatures = new AppFeatures(
{} as unknown as Logger,
@ -83,6 +94,7 @@ describe('AppFeatures', () => {
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
...SECURITY_BASE_CONFIG,
...SECURITY_APP_FEATURE_CONFIG['test-base-feature'],
subFeatures: [{ baz: 'baz' }],
});
});
@ -91,9 +103,7 @@ describe('AppFeatures', () => {
registerKibanaFeature: jest.fn(),
} as unknown as PluginSetupContract;
const appFeatureKeys = {
'test-cases-feature': true,
} as unknown as AppFeatureKeys;
const appFeatureKeys = ['test-cases-feature'] as unknown as AppFeatureKeys;
const appFeatures = new AppFeatures(
{} as unknown as Logger,
@ -105,6 +115,7 @@ describe('AppFeatures', () => {
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
...CASES_BASE_CONFIG,
...CASES_APP_FEATURE_CONFIG['test-cases-feature'],
subFeatures: [{ baz: 'baz' }],
});
});
});

View file

@ -12,25 +12,32 @@ import type { AppFeatureKibanaConfig, AppFeaturesConfig } from './types';
import {
getSecurityAppFeaturesConfig,
getSecurityBaseKibanaFeature,
getSecurityBaseKibanaSubFeatureIds,
} from './security_kibana_features';
import {
getCasesBaseKibanaFeature,
getCasesAppFeaturesConfig,
getCasesBaseKibanaSubFeatureIds,
} from './security_cases_kibana_features';
import { AppFeaturesConfigMerger } from './app_features_config_merger';
type AppFeaturesMap = Map<AppFeatureKey, boolean>;
import { casesSubFeaturesMap } from './security_cases_kibana_sub_features';
import { securitySubFeaturesMap } from './security_kibana_sub_features';
export class AppFeatures {
private merger: AppFeaturesConfigMerger;
private appFeatures?: AppFeaturesMap;
private securityFeatureConfigMerger: AppFeaturesConfigMerger;
private casesFeatureConfigMerger: AppFeaturesConfigMerger;
private appFeatures?: Set<AppFeatureKey>;
private featuresSetup?: FeaturesPluginSetup;
constructor(
private readonly logger: Logger,
private readonly experimentalFeatures: ExperimentalFeatures
) {
this.merger = new AppFeaturesConfigMerger(this.logger);
this.securityFeatureConfigMerger = new AppFeaturesConfigMerger(
this.logger,
securitySubFeaturesMap
);
this.casesFeatureConfigMerger = new AppFeaturesConfigMerger(this.logger, casesSubFeaturesMap);
}
public init(featuresSetup: FeaturesPluginSetup) {
@ -41,7 +48,7 @@ export class AppFeatures {
if (this.appFeatures) {
throw new Error('AppFeatures has already been initialized');
}
this.appFeatures = new Map(Object.entries(appFeatureKeys) as Array<[AppFeatureKey, boolean]>);
this.appFeatures = new Set(appFeatureKeys);
this.registerEnabledKibanaFeatures();
}
@ -49,7 +56,7 @@ export class AppFeatures {
if (!this.appFeatures) {
throw new Error('AppFeatures has not been initialized');
}
return this.appFeatures.get(appFeatureKey) ?? false;
return this.appFeatures.has(appFeatureKey);
}
private registerEnabledKibanaFeatures() {
@ -59,25 +66,31 @@ export class AppFeatures {
);
}
// register main security Kibana features
const securityBaseKibanaFeature = getSecurityBaseKibanaFeature(this.experimentalFeatures);
const securityBaseKibanaFeature = getSecurityBaseKibanaFeature();
const securityBaseKibanaSubFeatureIds = getSecurityBaseKibanaSubFeatureIds(
this.experimentalFeatures
);
const enabledSecurityAppFeaturesConfigs = this.getEnabledAppFeaturesConfigs(
getSecurityAppFeaturesConfig()
);
this.featuresSetup.registerKibanaFeature(
this.merger.mergeAppFeatureConfigs(
this.securityFeatureConfigMerger.mergeAppFeatureConfigs(
securityBaseKibanaFeature,
securityBaseKibanaSubFeatureIds,
enabledSecurityAppFeaturesConfigs
)
);
// register security cases Kibana features
const securityCasesBaseKibanaFeature = getCasesBaseKibanaFeature();
const securityCasesBaseKibanaSubFeatureIds = getCasesBaseKibanaSubFeatureIds();
const enabledCasesAppFeaturesConfigs = this.getEnabledAppFeaturesConfigs(
getCasesAppFeaturesConfig()
);
this.featuresSetup.registerKibanaFeature(
this.merger.mergeAppFeatureConfigs(
this.casesFeatureConfigMerger.mergeAppFeatureConfigs(
securityCasesBaseKibanaFeature,
securityCasesBaseKibanaSubFeatureIds,
enabledCasesAppFeaturesConfigs
)
);

View file

@ -9,59 +9,190 @@ import { loggingSystemMock } from '@kbn/core/server/mocks';
import { AppFeaturesConfigMerger } from './app_features_config_merger';
import type { Logger } from '@kbn/core/server';
import type { AppFeatureKibanaConfig } from './types';
import type { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import type { KibanaFeatureConfig, SubFeatureConfig } from '@kbn/features-plugin/common';
const category = {
id: 'security',
label: 'Security app category',
};
const baseKibanaFeature: KibanaFeatureConfig = {
id: 'FEATURE_ID',
name: 'Base Feature',
order: 1100,
category,
app: ['FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
privileges: {
all: {
api: ['api-read', 'api-write'],
app: ['FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
savedObject: {
all: [],
read: [],
},
ui: ['write', 'read'],
},
read: {
api: ['api-read'],
app: ['FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
savedObject: {
all: [],
read: [],
},
ui: ['read'],
},
},
};
const subFeature1: SubFeatureConfig = {
requireAllSpaces: true,
name: 'subFeature1',
description: 'Perform subFeature1 actions.',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: ['api-subFeature1'],
id: 'sub-feature-1_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['subFeature1'],
},
],
},
],
};
const subFeature2: SubFeatureConfig = {
requireAllSpaces: true,
name: 'subFeature2',
description: 'Perform subFeature2 actions.',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: ['api-readSubFeature2', 'api-writeSubFeature2'],
id: 'sub-feature-2_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['readSubFeature2', 'writeSubFeature2'],
},
{
api: ['api-readSubFeature2'],
id: 'sub-feature-2_read',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['readSubFeature2'],
},
],
},
],
};
const subFeature3: SubFeatureConfig = {
requireAllSpaces: true,
name: 'subFeature3',
description: 'Perform subFeature3 actions.',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: ['api-readSubFeature3', 'api-writeSubFeature3'],
id: 'sub-feature-3_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['readSubFeature3', 'writeSubFeature3'],
},
],
},
],
};
// Defines the order of the Security Cases sub features
export const subFeaturesMap = Object.freeze(
new Map<string, SubFeatureConfig>([
['subFeature1', subFeature1],
['subFeature2', subFeature2],
['subFeature3', subFeature3],
])
);
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
describe('AppFeaturesConfigMerger', () => {
// We don't need to update this test when cases config change
// It mocks simplified versions of cases config
it('merges a mocked version of cases config', () => {
const merger = new AppFeaturesConfigMerger(mockLogger);
const merger = new AppFeaturesConfigMerger(mockLogger, subFeaturesMap);
const category = {
id: 'security',
label: 'Security app category',
};
beforeEach(() => {
jest.clearAllMocks();
});
const securityCasesBaseKibanaFeature: KibanaFeatureConfig = {
id: 'CASES_FEATURE_ID',
name: 'Cases',
order: 1100,
category,
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
privileges: {
all: {
api: [],
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
savedObject: {
all: [],
read: [],
describe('main privileges', () => {
it('should merge enabled main privileges into base config', () => {
const enabledAppFeaturesConfigs: AppFeatureKibanaConfig[] = [
{
privileges: {
all: {
api: ['api-write', 'api-extra-all'],
ui: ['extra-all'],
cases: {
create: ['APP_ID'],
read: ['APP_ID'],
update: ['APP_ID'],
push: ['APP_ID'],
},
savedObject: {
all: ['someSavedObjectType'],
read: ['someSavedObjectType'],
},
},
read: {
api: ['api-extra-read'],
ui: ['extra-read'],
cases: {
read: ['APP_ID'],
},
savedObject: {
all: [],
read: ['someSavedObjectType'],
},
},
},
ui: [],
},
read: {
api: [],
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
savedObject: {
all: [],
read: [],
},
ui: [],
},
},
};
];
const enabledCasesAppFeaturesConfigs: AppFeatureKibanaConfig[] = [
{
cases: ['APP_ID'],
const merged = merger.mergeAppFeatureConfigs(
baseKibanaFeature,
[],
enabledAppFeaturesConfigs
);
expect(merged).toEqual({
...baseKibanaFeature,
privileges: {
all: {
api: ['casesApiTags.all'],
ui: ['casesCapabilities.all'],
api: ['api-read', 'api-write', 'api-extra-all'],
app: ['FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
ui: ['write', 'read', 'extra-all'],
cases: {
create: ['APP_ID'],
read: ['APP_ID'],
@ -69,69 +200,215 @@ describe('AppFeaturesConfigMerger', () => {
push: ['APP_ID'],
},
savedObject: {
all: ['filesSavedObjectTypes'],
read: ['filesSavedObjectTypes'],
all: ['someSavedObjectType'],
read: ['someSavedObjectType'],
},
},
read: {
api: ['casesApiTags.read'],
ui: ['casesCapabilities.read'],
api: ['api-read', 'api-extra-read'],
app: ['FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
ui: ['read', 'extra-read'],
cases: {
read: ['APP_ID'],
},
savedObject: {
all: [],
read: ['filesSavedObjectTypes'],
read: ['someSavedObjectType'],
},
},
},
subFeatures: [],
});
});
});
describe('subFeatureIds', () => {
it('adds base subFeatures in the correct order', () => {
const baseKibanaSubFeatureIds = ['subFeature2', 'subFeature3', 'subFeature1'];
const merged = merger.mergeAppFeatureConfigs(baseKibanaFeature, baseKibanaSubFeatureIds, []);
expect(merged.subFeatures).toEqual([subFeature1, subFeature2, subFeature3]);
});
it('should merge enabled subFeatures into base config in the correct order', () => {
const enabledAppFeaturesConfigs: AppFeatureKibanaConfig[] = [
{
subFeatureIds: ['subFeature3', 'subFeature1'],
},
];
const merged = merger.mergeAppFeatureConfigs(
baseKibanaFeature,
['subFeature2'],
enabledAppFeaturesConfigs
);
expect(merged).toEqual({
...baseKibanaFeature,
subFeatures: [subFeature1, subFeature2, subFeature3],
});
});
});
describe('subFeaturePrivileges', () => {
it('should merge enabled subFeatures with extra subFeaturePrivileges into base config in the correct order', () => {
const enabledAppFeaturesConfigs: AppFeatureKibanaConfig[] = [
{
subFeaturesPrivileges: [
{
id: 'sub-feature-1_all',
api: ['api-subFeature1-extra1', 'api-subFeature1-extra2'],
ui: ['subFeature1-extra1', 'subFeature1-extra2'],
},
{
id: 'sub-feature-2_read',
api: ['api-readSubFeature2-extra1', 'api-readSubFeature2-extra2'],
ui: ['readSubFeature2-extra1', 'readSubFeature2-extra2'],
},
],
},
{
subFeatureIds: ['subFeature3', 'subFeature1'],
},
];
const merged = merger.mergeAppFeatureConfigs(
baseKibanaFeature,
['subFeature2'],
enabledAppFeaturesConfigs
);
expect(merged).toEqual({
...baseKibanaFeature,
subFeatures: [
{
name: 'Delete',
...subFeature1,
privilegeGroups: [
{
groupType: 'independent',
...subFeature1.privilegeGroups[0],
privileges: [
{
api: ['casesApiTags.delete'],
id: 'cases_delete',
name: 'Delete cases and comments',
includeIn: 'all',
savedObject: {
all: ['filesSavedObjectTypes'],
read: ['filesSavedObjectTypes'],
},
cases: {
delete: ['APP_ID'],
},
ui: ['casesCapabilities.delete'],
...subFeature1.privilegeGroups[0].privileges[0],
api: ['api-subFeature1', 'api-subFeature1-extra1', 'api-subFeature1-extra2'],
ui: ['subFeature1', 'subFeature1-extra1', 'subFeature1-extra2'],
},
],
},
],
},
{
...subFeature2,
privilegeGroups: [
{
...subFeature2.privilegeGroups[0],
privileges: [
subFeature2.privilegeGroups[0].privileges[0],
{
...subFeature2.privilegeGroups[0].privileges[1],
api: [
'api-readSubFeature2',
'api-readSubFeature2-extra1',
'api-readSubFeature2-extra2',
],
ui: ['readSubFeature2', 'readSubFeature2-extra1', 'readSubFeature2-extra2'],
},
],
},
],
},
subFeature3,
],
});
});
it('should warn if there are subFeaturesPrivileges for a subFeature id that is not found', () => {
const subFeaturesPrivilegesId = 'sub-feature-1_all';
const enabledAppFeaturesConfigs: AppFeatureKibanaConfig[] = [
{
subFeaturesPrivileges: [
{
id: subFeaturesPrivilegesId,
api: ['api-subFeature1-extra1', 'api-subFeature1-extra2'],
ui: ['subFeature1-extra1', 'subFeature1-extra2'],
},
],
},
];
const merged = merger.mergeAppFeatureConfigs(
baseKibanaFeature,
['subFeature2', 'subFeature3'],
enabledAppFeaturesConfigs
);
expect(mockLogger.warn).toHaveBeenCalledWith(
`Trying to merge subFeaturesPrivileges ${subFeaturesPrivilegesId} but the subFeature privilege was not found`
);
expect(merged).toEqual({ ...baseKibanaFeature, subFeatures: [subFeature2, subFeature3] });
});
});
it('should merge everything at the same time', () => {
const enabledAppFeaturesConfigs: AppFeatureKibanaConfig[] = [
{
privileges: {
all: {
api: ['api-write', 'api-extra-all'],
ui: ['extra-all'],
cases: {
create: ['APP_ID'],
read: ['APP_ID'],
update: ['APP_ID'],
push: ['APP_ID'],
},
savedObject: {
all: ['someSavedObjectType'],
read: ['someSavedObjectType'],
},
},
read: {
api: ['api-extra-read'],
ui: ['extra-read'],
cases: {
read: ['APP_ID'],
},
savedObject: {
all: [],
read: ['someSavedObjectType'],
},
},
},
subFeatureIds: ['subFeature3', 'subFeature1'],
subFeaturesPrivileges: [
{
id: 'sub-feature-1_all',
api: ['api-subFeature1-extra1', 'api-subFeature1-extra2'],
ui: ['subFeature1-extra1', 'subFeature1-extra2'],
},
{
id: 'sub-feature-2_all',
api: ['api-writeSubFeature2-extra1', 'api-writeSubFeature2-extra2'],
ui: ['writeSubFeature2-extra1', 'writeSubFeature2-extra2'],
},
],
},
];
const baseKibanaSubFeatureIds = ['subFeature2'];
const merged = merger.mergeAppFeatureConfigs(
securityCasesBaseKibanaFeature,
enabledCasesAppFeaturesConfigs
baseKibanaFeature,
baseKibanaSubFeatureIds,
enabledAppFeaturesConfigs
);
expect(merged).toEqual({
id: 'CASES_FEATURE_ID',
name: 'Cases',
order: 1100,
category,
app: ['CASES_FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
cases: ['APP_ID'],
...baseKibanaFeature,
privileges: {
all: {
api: ['casesApiTags.all'],
app: ['CASES_FEATURE_ID', 'kibana'],
api: ['api-read', 'api-write', 'api-extra-all'],
app: ['FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
ui: ['write', 'read', 'extra-all'],
cases: {
create: ['APP_ID'],
read: ['APP_ID'],
@ -139,241 +416,67 @@ describe('AppFeaturesConfigMerger', () => {
push: ['APP_ID'],
},
savedObject: {
all: ['filesSavedObjectTypes'],
read: ['filesSavedObjectTypes'],
all: ['someSavedObjectType'],
read: ['someSavedObjectType'],
},
ui: ['casesCapabilities.all'],
},
read: {
api: ['casesApiTags.read'],
app: ['CASES_FEATURE_ID', 'kibana'],
api: ['api-read', 'api-extra-read'],
app: ['FEATURE_ID', 'kibana'],
catalogue: ['APP_ID'],
ui: ['read', 'extra-read'],
cases: {
read: ['APP_ID'],
},
savedObject: {
all: [],
read: ['filesSavedObjectTypes'],
read: ['someSavedObjectType'],
},
ui: ['casesCapabilities.read'],
},
},
subFeatures: [
{
name: 'Delete',
...subFeature1,
privilegeGroups: [
{
groupType: 'independent',
...subFeature1.privilegeGroups[0],
privileges: [
{
api: ['casesApiTags.delete'],
id: 'cases_delete',
name: 'Delete cases and comments',
includeIn: 'all',
savedObject: {
all: ['filesSavedObjectTypes'],
read: ['filesSavedObjectTypes'],
},
cases: {
delete: ['APP_ID'],
},
ui: ['casesCapabilities.delete'],
...subFeature1.privilegeGroups[0].privileges[0],
api: ['api-subFeature1', 'api-subFeature1-extra1', 'api-subFeature1-extra2'],
ui: ['subFeature1', 'subFeature1-extra1', 'subFeature1-extra2'],
},
],
},
],
},
],
});
});
it('merges a mocked version of security basic config', () => {
const merger = new AppFeaturesConfigMerger(mockLogger);
const category = {
id: 'security',
label: 'Security app category',
};
const securityCasesBaseKibanaFeature: KibanaFeatureConfig = {
id: 'SERVER_APP_ID',
name: 'Security',
order: 1100,
category,
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
management: {
insightsAndAlerting: ['triggersActions'],
},
alerting: ['THRESHOLD_RULE_TYPE_ID', 'NEW_TERMS_RULE_TYPE_ID'],
privileges: {
all: {
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
api: ['APP_ID', 'cloud-security-posture-read'],
savedObject: {
all: ['alert', 'CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE'],
read: [],
},
alerting: {
rule: {
all: ['SECURITY_RULE_TYPES'],
},
alert: {
all: ['SECURITY_RULE_TYPES'],
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'crud'],
},
read: {
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
api: ['APP_ID', 'lists-read', 'rac', 'cloud-security-posture-read'],
savedObject: {
all: [],
read: ['CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE'],
},
alerting: {
rule: {
read: ['SECURITY_RULE_TYPES'],
},
alert: {
all: ['SECURITY_RULE_TYPES'],
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show'],
},
},
subFeatures: [
{
requireAllSpaces: true,
privilegesTooltip: 'All Spaces is required for Host Isolation access.',
name: 'Host Isolation',
description: 'Perform the "isolate" and "release" response actions.',
...subFeature2,
privilegeGroups: [
{
groupType: 'mutually_exclusive',
...subFeature2.privilegeGroups[0],
privileges: [
{
api: [`APP_ID-writeHostIsolation`],
id: 'host_isolation_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeHostIsolation'],
},
],
},
],
},
],
};
const enabledCasesAppFeaturesConfigs: AppFeatureKibanaConfig[] = [
{
privileges: {
all: {
api: ['rules_load_prepackaged'],
ui: ['rules_load_prepackaged'],
},
},
},
];
const merged = merger.mergeAppFeatureConfigs(
securityCasesBaseKibanaFeature,
enabledCasesAppFeaturesConfigs
);
expect(merged).toEqual({
id: 'SERVER_APP_ID',
name: 'Security',
order: 1100,
category,
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
management: {
insightsAndAlerting: ['triggersActions'],
},
alerting: ['THRESHOLD_RULE_TYPE_ID', 'NEW_TERMS_RULE_TYPE_ID'],
privileges: {
all: {
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
api: ['APP_ID', 'cloud-security-posture-read', 'rules_load_prepackaged'],
savedObject: {
all: ['alert', 'CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE'],
read: [],
},
alerting: {
rule: {
all: ['SECURITY_RULE_TYPES'],
},
alert: {
all: ['SECURITY_RULE_TYPES'],
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'crud', 'rules_load_prepackaged'],
},
read: {
app: ['APP_ID', 'CLOUD_POSTURE_APP_ID', 'kibana'],
catalogue: ['APP_ID'],
api: ['APP_ID', 'lists-read', 'rac', 'cloud-security-posture-read'],
savedObject: {
all: [],
read: ['CLOUD_POSTURE_SAVED_OBJECT_RULE_TYPE'],
},
alerting: {
rule: {
read: ['SECURITY_RULE_TYPES'],
},
alert: {
all: ['SECURITY_RULE_TYPES'],
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show'],
},
},
subFeatures: [
{
requireAllSpaces: true,
privilegesTooltip: 'All Spaces is required for Host Isolation access.',
name: 'Host Isolation',
description: 'Perform the "isolate" and "release" response actions.',
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [`APP_ID-writeHostIsolation`],
id: 'host_isolation_all',
includeIn: 'none',
name: 'All',
savedObject: {
all: [],
read: [],
},
ui: ['writeHostIsolation'],
...subFeature2.privilegeGroups[0].privileges[0],
api: [
'api-readSubFeature2',
'api-writeSubFeature2',
'api-writeSubFeature2-extra1',
'api-writeSubFeature2-extra2',
],
ui: [
'readSubFeature2',
'writeSubFeature2',
'writeSubFeature2-extra1',
'writeSubFeature2-extra2',
],
},
subFeature2.privilegeGroups[0].privileges[1],
],
},
],
},
subFeature3,
],
});
});

View file

@ -7,11 +7,18 @@
import { cloneDeep, mergeWith, isArray, uniq } from 'lodash';
import type { Logger } from '@kbn/core/server';
import type { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import type { AppFeatureKibanaConfig, SubFeaturesPrivileges } from './types';
import type { KibanaFeatureConfig, SubFeatureConfig } from '@kbn/features-plugin/common';
import type {
AppFeatureKibanaConfig,
BaseKibanaFeatureConfig,
SubFeaturesPrivileges,
} from './types';
export class AppFeaturesConfigMerger {
constructor(private readonly logger: Logger) {}
export class AppFeaturesConfigMerger<T extends string = string> {
constructor(
private readonly logger: Logger,
private readonly subFeaturesMap: Map<T, SubFeatureConfig>
) {}
/**
* Merges `appFeaturesConfigs` into `kibanaFeatureConfig`.
@ -20,25 +27,45 @@ export class AppFeaturesConfigMerger {
* @returns mergedKibanaFeatureConfig the merged KibanaFeatureConfig
* */
public mergeAppFeatureConfigs(
kibanaFeatureConfig: KibanaFeatureConfig,
kibanaFeatureConfig: BaseKibanaFeatureConfig,
kibanaSubFeatureIds: T[],
appFeaturesConfigs: AppFeatureKibanaConfig[]
): KibanaFeatureConfig {
const mergedKibanaFeatureConfig = cloneDeep(kibanaFeatureConfig);
const mergedKibanaFeatureConfig = cloneDeep(kibanaFeatureConfig) as KibanaFeatureConfig;
const subFeaturesPrivilegesToMerge: SubFeaturesPrivileges[] = [];
const enabledSubFeaturesIndexed = Object.fromEntries(
kibanaSubFeatureIds.map((id) => [id, true])
);
appFeaturesConfigs.forEach((appFeatureConfig) => {
const { subFeaturesPrivileges, ...appFeatureConfigToMerge } = cloneDeep(appFeatureConfig);
const { subFeaturesPrivileges, subFeatureIds, ...appFeatureConfigToMerge } =
cloneDeep(appFeatureConfig);
subFeatureIds?.forEach((subFeatureId) => {
enabledSubFeaturesIndexed[subFeatureId] = true;
});
if (subFeaturesPrivileges) {
subFeaturesPrivilegesToMerge.push(...subFeaturesPrivileges);
}
mergeWith(mergedKibanaFeatureConfig, appFeatureConfigToMerge, featureConfigMerger);
});
// add subFeaturePrivileges at the end to make sure all enabled subFeatures are merged
subFeaturesPrivilegesToMerge.forEach((subFeaturesPrivileges) => {
this.mergeSubFeaturesPrivileges(mergedKibanaFeatureConfig.subFeatures, subFeaturesPrivileges);
// generate sub features configs from enabled sub feature ids, preserving map order
const mergedKibanaSubFeatures: SubFeatureConfig[] = [];
this.subFeaturesMap.forEach((subFeature, id) => {
if (enabledSubFeaturesIndexed[id]) {
mergedKibanaSubFeatures.push(cloneDeep(subFeature));
}
});
// add extra privileges to subFeatures
subFeaturesPrivilegesToMerge.forEach((subFeaturesPrivileges) => {
this.mergeSubFeaturesPrivileges(mergedKibanaSubFeatures, subFeaturesPrivileges);
});
mergedKibanaFeatureConfig.subFeatures = mergedKibanaSubFeatures;
return mergedKibanaFeatureConfig;
}
@ -49,13 +76,9 @@ export class AppFeaturesConfigMerger {
* @returns void
* */
private mergeSubFeaturesPrivileges(
subFeatures: KibanaFeatureConfig['subFeatures'],
subFeatures: SubFeatureConfig[],
subFeaturesPrivileges: SubFeaturesPrivileges
): void {
if (!subFeatures) {
this.logger.warn('Trying to merge subFeaturesPrivileges but no subFeatures found');
return;
}
const merged = subFeatures.find(({ privilegeGroups }) =>
privilegeGroups.some(({ privileges }) => {
const subFeaturePrivilegeToUpdate = privileges.find(

View file

@ -8,21 +8,20 @@
import { i18n } from '@kbn/i18n';
import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects';
import type { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import {
createUICapabilities as createCasesUICapabilities,
getApiTags as getCasesApiTags,
} from '@kbn/cases-plugin/common';
import type { AppFeaturesCasesConfig } from './types';
import type { AppFeaturesCasesConfig, BaseKibanaFeatureConfig } from './types';
import { APP_ID, CASES_FEATURE_ID } from '../../../common/constants';
import { casesSubFeatureDelete } from './security_cases_kibana_sub_features';
import { CasesSubFeatureId } from './security_cases_kibana_sub_features';
import { AppFeatureCasesKey } from '../../../common/types/app_features';
const casesCapabilities = createCasesUICapabilities();
const casesApiTags = getCasesApiTags(APP_ID);
export const getCasesBaseKibanaFeature = (): KibanaFeatureConfig => ({
export const getCasesBaseKibanaFeature = (): BaseKibanaFeatureConfig => ({
id: CASES_FEATURE_ID,
name: i18n.translate('xpack.securitySolution.featureRegistry.linkSecuritySolutionCaseTitle', {
defaultMessage: 'Cases',
@ -63,11 +62,23 @@ export const getCasesBaseKibanaFeature = (): KibanaFeatureConfig => ({
ui: casesCapabilities.read,
},
},
subFeatures: [casesSubFeatureDelete],
});
// It maps the AppFeatures keys to Kibana privileges
export const getCasesBaseKibanaSubFeatureIds = (): CasesSubFeatureId[] => [
CasesSubFeatureId.deleteCases,
];
/**
* Maps the AppFeatures keys to Kibana privileges that will be merged
* into the base privileges config for the Security Cases app.
*
* Privileges can be added in different ways:
* - `privileges`: the privileges that will be added directly into the main Security Cases feature.
* - `subFeatureIds`: the ids of the sub-features that will be added into the Cases subFeatures entry.
* - `subFeaturesPrivileges`: the privileges that will be added into the existing Cases subFeature with the privilege `id` specified.
*/
export const getCasesAppFeaturesConfig = (): AppFeaturesCasesConfig => ({
// TODO Add cases connector configuration
[AppFeatureCasesKey.casesConnectors]: {},
[AppFeatureCasesKey.casesConnectors]: {
// TODO: Add cases connector configuration privileges
},
});

View file

@ -17,7 +17,7 @@ import { APP_ID } from '../../../common/constants';
const casesCapabilities = createCasesUICapabilities();
const casesApiTags = getCasesApiTags(APP_ID);
export const casesSubFeatureDelete: SubFeatureConfig = {
const deleteCasesSubFeature: SubFeatureConfig = {
name: i18n.translate('xpack.securitySolution.featureRegistry.deleteSubFeatureName', {
defaultMessage: 'Delete',
}),
@ -45,3 +45,14 @@ export const casesSubFeatureDelete: SubFeatureConfig = {
},
],
};
export enum CasesSubFeatureId {
deleteCases = 'deleteCasesSubFeature',
}
// Defines all the ordered Security Cases subFeatures available
export const casesSubFeaturesMap = Object.freeze(
new Map<CasesSubFeatureId, SubFeatureConfig>([
[CasesSubFeatureId.deleteCases, deleteCasesSubFeature],
])
);

View file

@ -7,7 +7,6 @@
import { i18n } from '@kbn/i18n';
import type { KibanaFeatureConfig, SubFeatureConfig } from '@kbn/features-plugin/common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common';
import { EXCEPTION_LIST_NAMESPACE_AGNOSTIC } from '@kbn/securitysolution-list-constants';
@ -23,20 +22,8 @@ import {
import { APP_ID, LEGACY_NOTIFICATIONS_ID, SERVER_APP_ID } from '../../../common/constants';
import { savedObjectTypes } from '../../saved_objects';
import type { ExperimentalFeatures } from '../../../common/experimental_features';
import {
blocklistSubFeature,
endpointListSubFeature,
eventFiltersSubFeature,
executeActionSubFeature,
fileOperationsSubFeature,
hostIsolationExceptionsSubFeature,
hostIsolationSubFeature,
policyManagementSubFeature,
processOperationsSubFeature,
responseActionsHistorySubFeature,
trustedApplicationsSubFeature,
} from './security_kibana_sub_features';
import type { AppFeaturesSecurityConfig } from './types';
import { SecuritySubFeatureId } from './security_kibana_sub_features';
import type { AppFeaturesSecurityConfig, BaseKibanaFeatureConfig } from './types';
import { AppFeatureSecurityKey } from '../../../common/types/app_features';
// Same as the plugin id defined by Cloud Security Posture
@ -55,9 +42,7 @@ const SECURITY_RULE_TYPES = [
NEW_TERMS_RULE_TYPE_ID,
];
export const getSecurityBaseKibanaFeature = (
experimentalFeatures: ExperimentalFeatures
): KibanaFeatureConfig => ({
export const getSecurityBaseKibanaFeature = (): BaseKibanaFeatureConfig => ({
id: SERVER_APP_ID,
name: i18n.translate('xpack.securitySolution.featureRegistry.linkSecuritySolutionTitle', {
defaultMessage: 'Security',
@ -135,42 +120,51 @@ export const getSecurityBaseKibanaFeature = (
ui: ['show'],
},
},
subFeatures: getSubFeatures(experimentalFeatures),
});
function getSubFeatures(experimentalFeatures: ExperimentalFeatures) {
const subFeatures: SubFeatureConfig[] = [];
export const getSecurityBaseKibanaSubFeatureIds = (
experimentalFeatures: ExperimentalFeatures
): SecuritySubFeatureId[] => {
const subFeatureIds: SecuritySubFeatureId[] = [];
if (experimentalFeatures.endpointRbacEnabled) {
subFeatures.push(
endpointListSubFeature,
trustedApplicationsSubFeature,
hostIsolationExceptionsSubFeature,
blocklistSubFeature,
eventFiltersSubFeature,
policyManagementSubFeature
subFeatureIds.push(
SecuritySubFeatureId.endpointList,
SecuritySubFeatureId.trustedApplications,
SecuritySubFeatureId.hostIsolationExceptions,
SecuritySubFeatureId.blocklist,
SecuritySubFeatureId.eventFilters,
SecuritySubFeatureId.policyManagement
);
}
if (experimentalFeatures.endpointRbacEnabled || experimentalFeatures.endpointRbacV1Enabled) {
subFeatures.push(
responseActionsHistorySubFeature,
hostIsolationSubFeature,
processOperationsSubFeature
subFeatureIds.push(
SecuritySubFeatureId.responseActionsHistory,
SecuritySubFeatureId.hostIsolation,
SecuritySubFeatureId.processOperations
);
}
if (experimentalFeatures.responseActionGetFileEnabled) {
subFeatures.push(fileOperationsSubFeature);
subFeatureIds.push(SecuritySubFeatureId.fileOperations);
}
// planned for 8.8
if (experimentalFeatures.responseActionExecuteEnabled) {
subFeatures.push(executeActionSubFeature);
subFeatureIds.push(SecuritySubFeatureId.executeAction);
}
return subFeatures;
}
return subFeatureIds;
};
// maps the AppFeatures keys to Kibana privileges
/**
* Maps the AppFeatures keys to Kibana privileges that will be merged
* into the base privileges config for the Security app.
*
* Privileges can be added in different ways:
* - `privileges`: the privileges that will be added directly into the main Security feature.
* - `subFeatureIds`: the ids of the sub-features that will be added into the Security subFeatures entry.
* - `subFeaturesPrivileges`: the privileges that will be added into the existing Security subFeature with the privilege `id` specified.
*/
export const getSecurityAppFeaturesConfig = (): AppFeaturesSecurityConfig => {
return {
[AppFeatureSecurityKey.advancedInsights]: {

View file

@ -10,7 +10,7 @@ import type { SubFeatureConfig } from '@kbn/features-plugin/common';
import { EXCEPTION_LIST_NAMESPACE_AGNOSTIC } from '@kbn/securitysolution-list-constants';
import { APP_ID } from '../../../common';
export const endpointListSubFeature: SubFeatureConfig = {
const endpointListSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.endpointList.privilegesTooltip',
@ -58,7 +58,7 @@ export const endpointListSubFeature: SubFeatureConfig = {
},
],
};
export const trustedApplicationsSubFeature: SubFeatureConfig = {
const trustedApplicationsSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.trustedApplications.privilegesTooltip',
@ -112,7 +112,7 @@ export const trustedApplicationsSubFeature: SubFeatureConfig = {
},
],
};
export const hostIsolationExceptionsSubFeature: SubFeatureConfig = {
const hostIsolationExceptionsSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolationExceptions.privilegesTooltip',
@ -169,7 +169,7 @@ export const hostIsolationExceptionsSubFeature: SubFeatureConfig = {
},
],
};
export const blocklistSubFeature: SubFeatureConfig = {
const blocklistSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.blockList.privilegesTooltip',
@ -223,7 +223,7 @@ export const blocklistSubFeature: SubFeatureConfig = {
},
],
};
export const eventFiltersSubFeature: SubFeatureConfig = {
const eventFiltersSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.eventFilters.privilegesTooltip',
@ -277,7 +277,7 @@ export const eventFiltersSubFeature: SubFeatureConfig = {
},
],
};
export const policyManagementSubFeature: SubFeatureConfig = {
const policyManagementSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.policyManagement.privilegesTooltip',
@ -326,7 +326,7 @@ export const policyManagementSubFeature: SubFeatureConfig = {
],
};
export const responseActionsHistorySubFeature: SubFeatureConfig = {
const responseActionsHistorySubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.responseActionsHistory.privilegesTooltip',
@ -376,7 +376,7 @@ export const responseActionsHistorySubFeature: SubFeatureConfig = {
},
],
};
export const hostIsolationSubFeature: SubFeatureConfig = {
const hostIsolationSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.hostIsolation.privilegesTooltip',
@ -411,7 +411,7 @@ export const hostIsolationSubFeature: SubFeatureConfig = {
],
};
export const processOperationsSubFeature: SubFeatureConfig = {
const processOperationsSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.processOperations.privilegesTooltip',
@ -447,7 +447,7 @@ export const processOperationsSubFeature: SubFeatureConfig = {
},
],
};
export const fileOperationsSubFeature: SubFeatureConfig = {
const fileOperationsSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.fileOperations.privilegesTooltip',
@ -486,7 +486,7 @@ export const fileOperationsSubFeature: SubFeatureConfig = {
// execute operations are not available in 8.7,
// but will be available in 8.8
export const executeActionSubFeature: SubFeatureConfig = {
const executeActionSubFeature: SubFeatureConfig = {
requireAllSpaces: true,
privilegesTooltip: i18n.translate(
'xpack.securitySolution.featureRegistry.subFeatures.executeOperations.privilegesTooltip',
@ -523,3 +523,34 @@ export const executeActionSubFeature: SubFeatureConfig = {
},
],
};
export enum SecuritySubFeatureId {
endpointList = 'endpointListSubFeature',
trustedApplications = 'trustedApplicationsSubFeature',
hostIsolationExceptions = 'hostIsolationExceptionsSubFeature',
blocklist = 'blocklistSubFeature',
eventFilters = 'eventFiltersSubFeature',
policyManagement = 'policyManagementSubFeature',
responseActionsHistory = 'responseActionsHistorySubFeature',
hostIsolation = 'hostIsolationSubFeature',
processOperations = 'processOperationsSubFeature',
fileOperations = 'fileOperationsSubFeature',
executeAction = 'executeActionSubFeature',
}
// Defines all the ordered Security subFeatures available
export const securitySubFeaturesMap = Object.freeze(
new Map<SecuritySubFeatureId, SubFeatureConfig>([
[SecuritySubFeatureId.endpointList, endpointListSubFeature],
[SecuritySubFeatureId.trustedApplications, trustedApplicationsSubFeature],
[SecuritySubFeatureId.hostIsolationExceptions, hostIsolationExceptionsSubFeature],
[SecuritySubFeatureId.blocklist, blocklistSubFeature],
[SecuritySubFeatureId.eventFilters, eventFiltersSubFeature],
[SecuritySubFeatureId.policyManagement, policyManagementSubFeature],
[SecuritySubFeatureId.responseActionsHistory, responseActionsHistorySubFeature],
[SecuritySubFeatureId.hostIsolation, hostIsolationSubFeature],
[SecuritySubFeatureId.processOperations, processOperationsSubFeature],
[SecuritySubFeatureId.fileOperations, fileOperationsSubFeature],
[SecuritySubFeatureId.executeAction, executeActionSubFeature],
])
);

View file

@ -10,10 +10,22 @@ import type { AppFeatureKey } from '../../../common';
import type { AppFeatureSecurityKey, AppFeatureCasesKey } from '../../../common/types/app_features';
import type { RecursivePartial } from '../../../common/utility_types';
export type BaseKibanaFeatureConfig = Omit<KibanaFeatureConfig, 'subFeatures'>;
export type SubFeaturesPrivileges = RecursivePartial<SubFeaturePrivilegeConfig>;
export type AppFeatureKibanaConfig = RecursivePartial<KibanaFeatureConfig> & {
subFeaturesPrivileges?: SubFeaturesPrivileges[];
};
export type AppFeaturesConfig = Record<AppFeatureKey, AppFeatureKibanaConfig>;
export type AppFeaturesSecurityConfig = Record<AppFeatureSecurityKey, AppFeatureKibanaConfig>;
export type AppFeaturesCasesConfig = Record<AppFeatureCasesKey, AppFeatureKibanaConfig>;
export type AppFeatureKibanaConfig<T extends string = string> =
RecursivePartial<BaseKibanaFeatureConfig> & {
subFeatureIds?: T[];
subFeaturesPrivileges?: SubFeaturesPrivileges[];
};
export type AppFeaturesConfig<T extends string = string> = Record<
AppFeatureKey,
AppFeatureKibanaConfig<T>
>;
export type AppFeaturesSecurityConfig<T extends string = string> = Record<
AppFeatureSecurityKey,
AppFeatureKibanaConfig<T>
>;
export type AppFeaturesCasesConfig<T extends string = string> = Record<
AppFeatureCasesKey,
AppFeatureKibanaConfig<T>
>;

View file

@ -7,13 +7,23 @@
import { schema, TypeOf } from '@kbn/config-schema';
export const productLineId = schema.oneOf([
schema.literal('securityEssentials'),
schema.literal('securityComplete'),
export const productLine = schema.oneOf([
schema.literal('security'),
schema.literal('endpoint'),
schema.literal('cloud'),
]);
export type SecurityProductLineId = TypeOf<typeof productLineId>;
export type SecurityProductLine = TypeOf<typeof productLine>;
export const productLineIds = schema.arrayOf<SecurityProductLineId>(productLineId, {
defaultValue: ['securityEssentials'],
export const productTier = schema.oneOf([schema.literal('essentials'), schema.literal('complete')]);
export type SecurityProductTier = TypeOf<typeof productTier>;
export const productType = schema.object({
product_line: productLine,
product_tier: productTier,
});
export type SecurityProductLineIds = TypeOf<typeof productLineIds>;
export type SecurityProductType = TypeOf<typeof productType>;
export const productTypes = schema.arrayOf<SecurityProductType>(productType, {
defaultValue: [],
});
export type SecurityProductTypes = TypeOf<typeof productTypes>;

View file

@ -5,10 +5,24 @@
* 2.0.
*/
import { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import type { SecurityProductLineId } from '../config';
import { AppFeatureKey, type AppFeatureKeys } from '@kbn/security-solution-plugin/common';
import type { SecurityProductLine, SecurityProductTier } from '../config';
export const PLI_APP_FEATURES: Record<SecurityProductLineId, readonly AppFeatureKey[]> = {
securityEssentials: [],
securityComplete: [AppFeatureKey.advancedInsights, AppFeatureKey.casesConnectors],
type PliAppFeatures = Readonly<
Record<SecurityProductLine, Readonly<Record<SecurityProductTier, Readonly<AppFeatureKeys>>>>
>;
export const PLI_APP_FEATURES: PliAppFeatures = {
security: {
essentials: [],
complete: [AppFeatureKey.advancedInsights, AppFeatureKey.casesConnectors],
},
endpoint: {
essentials: [],
complete: [],
},
cloud: {
essentials: [],
complete: [],
},
} as const;

View file

@ -8,25 +8,65 @@ import { getProductAppFeatures } from './pli_features';
import * as pliConfig from './pli_config';
describe('getProductAppFeatures', () => {
it('returns the union of all enabled PLIs features', () => {
it('should return the essentials PLIs features', () => {
// @ts-ignore reassigning readonly value for testing
pliConfig.PLI_APP_FEATURES = { securityEssentials: ['foo'], securityComplete: ['baz'] };
pliConfig.PLI_APP_FEATURES = {
security: {
essentials: ['foo'],
complete: ['baz'],
},
};
expect(getProductAppFeatures(['securityEssentials', 'securityComplete'])).toEqual({
foo: true,
baz: true,
});
const appFeatureKeys = getProductAppFeatures([
{ product_line: 'security', product_tier: 'essentials' },
]);
expect(appFeatureKeys).toEqual(['foo']);
});
it('returns a single PLI when only one is enabled', () => {
it('should return the complete PLIs features, which includes essentials', () => {
// @ts-ignore reassigning readonly value for testing
pliConfig.PLI_APP_FEATURES = { securityEssentials: [], securityComplete: ['foo'] };
expect(getProductAppFeatures(['securityEssentials', 'securityComplete'])).toEqual({
foo: true,
});
pliConfig.PLI_APP_FEATURES = {
security: {
essentials: ['foo'],
complete: ['baz'],
},
};
const appFeatureKeys = getProductAppFeatures([
{ product_line: 'security', product_tier: 'complete' },
]);
expect(appFeatureKeys).toEqual(['foo', 'baz']);
});
it('should return the union of all enabled PLIs features without duplicates', () => {
// @ts-ignore reassigning readonly value for testing
pliConfig.PLI_APP_FEATURES = {
security: {
essentials: ['foo'],
complete: ['baz'],
},
endpoint: {
essentials: ['bar', 'repeated'],
complete: ['qux', 'quux'],
},
cloud: {
essentials: ['corge', 'garply', 'repeated'],
complete: ['grault'],
},
};
const appFeatureKeys = getProductAppFeatures([
{ product_line: 'security', product_tier: 'essentials' },
{ product_line: 'endpoint', product_tier: 'complete' },
{ product_line: 'cloud', product_tier: 'essentials' },
]);
expect(appFeatureKeys).toEqual(['foo', 'bar', 'repeated', 'qux', 'quux', 'corge', 'garply']);
});
it('returns an empty object if no PLIs are enabled', () => {
expect(getProductAppFeatures([])).toEqual({});
expect(getProductAppFeatures([])).toEqual([]);
});
});

View file

@ -6,19 +6,24 @@
*/
import type { AppFeatureKeys } from '@kbn/security-solution-plugin/common';
import { SecurityProductLineId } from '../config';
import uniq from 'lodash/fp/uniq';
import type { SecurityProductTypes } from '../config';
import { PLI_APP_FEATURES } from './pli_config';
/**
* Returns the U (union) of all enabled PLIs features in a single object.
* Returns the U (union) of all PLIs from the enabled productTypes in a single array.
*/
export const getProductAppFeatures = (productLineIds: SecurityProductLineId[]): AppFeatureKeys =>
productLineIds.reduce<AppFeatureKeys>((appFeatures, productLineId) => {
const productAppFeatures = PLI_APP_FEATURES[productLineId];
productAppFeatures.forEach((featureName) => {
appFeatures[featureName] = true;
});
return appFeatures;
}, {} as AppFeatureKeys);
export const getProductAppFeatures = (productTypes: SecurityProductTypes): AppFeatureKeys => {
const appFeatureKeys = productTypes.reduce<AppFeatureKeys>(
(appFeatures, { product_line: line, product_tier: tier }) => {
if (tier === 'complete') {
// Adding all "essentials" PLIs when tier is "complete"
appFeatures.push(...PLI_APP_FEATURES[line].essentials);
}
appFeatures.push(...PLI_APP_FEATURES[line][tier]);
return appFeatures;
},
[]
);
return uniq(appFeatureKeys);
};

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import { PLI_APP_FEATURES } from '../../../../common/pli/pli_config';
export const useProductTypeByPLI = (requiredPLI: AppFeatureKey): string | null => {
if (PLI_APP_FEATURES.security.essentials.includes(requiredPLI)) {
return 'Security Essentials';
}
if (PLI_APP_FEATURES.security.complete.includes(requiredPLI)) {
return 'Security Complete';
}
if (PLI_APP_FEATURES.endpoint.essentials.includes(requiredPLI)) {
return 'Endpoint Essentials';
}
if (PLI_APP_FEATURES.endpoint.complete.includes(requiredPLI)) {
return 'Endpoint Complete';
}
if (PLI_APP_FEATURES.cloud.essentials.includes(requiredPLI)) {
return 'Cloud Essentials';
}
if (PLI_APP_FEATURES.cloud.complete.includes(requiredPLI)) {
return 'Cloud Complete';
}
return null;
};

View file

@ -7,13 +7,12 @@
import React from 'react';
import { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
import { SecurityProductLineIds } from '../../../../common/config';
import type { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import { useProductTypeByPLI } from '../hooks/use_product_type_by_pli';
export const GenericUpsellingPage: React.FC<{ projectPLIs: SecurityProductLineIds }> = React.memo(
({ projectPLIs }) => {
const upsellingPLI = projectPLIs.includes('securityComplete')
? 'Security Complete'
: 'Security Essential';
export const GenericUpsellingPage: React.FC<{ requiredPLI: AppFeatureKey }> = React.memo(
({ requiredPLI }) => {
const productTypeRequired = useProductTypeByPLI(requiredPLI);
return (
<EuiEmptyPrompt
@ -21,7 +20,7 @@ export const GenericUpsellingPage: React.FC<{ projectPLIs: SecurityProductLineId
title={<>This is a testing component for a Serverless upselling prompt.</>}
body={
<>
Get <EuiLink href="#">{upsellingPLI}</EuiLink> to enable this feature
Get <EuiLink href="#">{productTypeRequired}</EuiLink> to enable this feature
<br />
<br />
<iframe

View file

@ -7,13 +7,12 @@
import React from 'react';
import { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
import { SecurityProductLineIds } from '../../../../common/config';
import type { AppFeatureKey } from '@kbn/security-solution-plugin/common';
import { useProductTypeByPLI } from '../hooks/use_product_type_by_pli';
export const GenericUpsellingSection: React.FC<{ projectPLIs: SecurityProductLineIds }> =
React.memo(({ projectPLIs }) => {
const upsellingPLI = projectPLIs.includes('securityComplete')
? 'Security Complete'
: 'Security Essential';
export const GenericUpsellingSection: React.FC<{ requiredPLI: AppFeatureKey }> = React.memo(
({ requiredPLI }) => {
const productTypeRequired = useProductTypeByPLI(requiredPLI);
return (
<EuiEmptyPrompt
@ -21,7 +20,7 @@ export const GenericUpsellingSection: React.FC<{ projectPLIs: SecurityProductLin
title={<>This is a testing component for a Serverless upselling prompt.</>}
body={
<>
Get <EuiLink href="#">{upsellingPLI}</EuiLink> to enable this feature
Get <EuiLink href="#">{productTypeRequired}</EuiLink> to enable this feature
<br />
<br />
<iframe
@ -37,7 +36,8 @@ export const GenericUpsellingSection: React.FC<{ projectPLIs: SecurityProductLin
}
/>
);
});
}
);
// eslint-disable-next-line import/no-default-export
export { GenericUpsellingSection as default };

View file

@ -6,16 +6,24 @@
*/
import { UpsellingService } from '@kbn/security-solution-plugin/public';
import { registerUpsellings } from './register_upsellings';
import { ALL_APP_FEATURE_KEYS } from '@kbn/security-solution-plugin/common';
import { registerUpsellings, upsellingPages, upsellingSections } from './register_upsellings';
import type { SecurityProductTypes } from '../../../common/config';
const mockGetProductAppFeatures = jest.fn();
jest.mock('../../../common/pli/pli_features', () => ({
getProductAppFeatures: () => mockGetProductAppFeatures(),
}));
const allProductTypes: SecurityProductTypes = [
{ product_line: 'security', product_tier: 'complete' },
{ product_line: 'endpoint', product_tier: 'complete' },
{ product_line: 'cloud', product_tier: 'complete' },
];
describe('registerUpsellings', () => {
it('registers entity analytics upsellings page and section when PLIs features are disabled', () => {
mockGetProductAppFeatures.mockReturnValue({}); // return empty object to simulate no features enabled
it('should not register anything when all PLIs features are enabled', () => {
mockGetProductAppFeatures.mockReturnValue(ALL_APP_FEATURE_KEYS);
const registerPages = jest.fn();
const registerSections = jest.fn();
@ -24,20 +32,37 @@ describe('registerUpsellings', () => {
registerSections,
} as unknown as UpsellingService;
registerUpsellings(upselling, ['securityEssentials', 'securityComplete']);
registerUpsellings(upselling, allProductTypes);
expect(registerPages).toHaveBeenCalledTimes(1);
expect(registerPages).toHaveBeenCalledWith(
expect.objectContaining({
['entity-analytics']: expect.any(Function),
})
);
expect(registerPages).toHaveBeenCalledWith({});
expect(registerSections).toHaveBeenCalledTimes(1);
expect(registerSections).toHaveBeenCalledWith(
expect.objectContaining({
entity_analytics_panel: expect.any(Function),
})
expect(registerSections).toHaveBeenCalledWith({});
});
it('should register all upsellings pages and sections when PLIs features are disabled', () => {
mockGetProductAppFeatures.mockReturnValue([]);
const registerPages = jest.fn();
const registerSections = jest.fn();
const upselling = {
registerPages,
registerSections,
} as unknown as UpsellingService;
registerUpsellings(upselling, allProductTypes);
const expectedPagesObject = Object.fromEntries(
upsellingPages.map(({ pageName }) => [pageName, expect.any(Function)])
);
expect(registerPages).toHaveBeenCalledTimes(1);
expect(registerPages).toHaveBeenCalledWith(expectedPagesObject);
const expectedSectionsObject = Object.fromEntries(
upsellingSections.map(({ id }) => [id, expect.any(Function)])
);
expect(registerSections).toHaveBeenCalledTimes(1);
expect(registerSections).toHaveBeenCalledWith(expectedSectionsObject);
});
});

View file

@ -12,15 +12,14 @@ import type {
SectionUpsellings,
UpsellingSectionId,
} from '@kbn/security-solution-plugin/public';
import type { SecurityProductLineIds } from '../../../common/config';
import type { SecurityProductTypes } from '../../../common/config';
import { getProductAppFeatures } from '../../../common/pli/pli_features';
const GenericUpsellingPageLazy = lazy(() => import('./pages/generic_upselling_page'));
const GenericUpsellingSectionLazy = lazy(() => import('./pages/generic_upselling_section'));
interface UpsellingsConfig {
feature: AppFeatureKey;
pli: AppFeatureKey;
component: React.ComponentType;
}
@ -29,13 +28,13 @@ type UpsellingSections = Array<UpsellingsConfig & { id: UpsellingSectionId }>;
export const registerUpsellings = (
upselling: UpsellingService,
projectPLIs: SecurityProductLineIds
productTypes: SecurityProductTypes
) => {
const PLIsFeatures = getProductAppFeatures(projectPLIs);
const enabledPLIsSet = new Set(getProductAppFeatures(productTypes));
const upsellingPages = getUpsellingPages(projectPLIs).reduce<PageUpsellings>(
(pageUpsellings, { pageName, feature, component }) => {
if (!PLIsFeatures[feature]) {
const upsellingPagesToRegister = upsellingPages.reduce<PageUpsellings>(
(pageUpsellings, { pageName, pli, component }) => {
if (!enabledPLIsSet.has(pli)) {
pageUpsellings[pageName] = component;
}
return pageUpsellings;
@ -43,9 +42,9 @@ export const registerUpsellings = (
{}
);
const upsellingSections = getUpsellingSections(projectPLIs).reduce<SectionUpsellings>(
(sectionUpsellings, { id, feature, component }) => {
if (!PLIsFeatures[feature]) {
const upsellingSectionsToRegister = upsellingSections.reduce<SectionUpsellings>(
(sectionUpsellings, { id, pli, component }) => {
if (!enabledPLIsSet.has(pli)) {
sectionUpsellings[id] = component;
}
return sectionUpsellings;
@ -53,23 +52,24 @@ export const registerUpsellings = (
{}
);
upselling.registerPages(upsellingPages);
upselling.registerSections(upsellingSections);
upselling.registerPages(upsellingPagesToRegister);
upselling.registerSections(upsellingSectionsToRegister);
};
// Upselling configuration for pages and sections components
const getUpsellingPages = (projectPLIs: SecurityProductLineIds): UpsellingPages => [
// Upsellings for entire pages, linked to a SecurityPageName
export const upsellingPages: UpsellingPages = [
{
pageName: SecurityPageName.entityAnalytics,
feature: AppFeatureKey.advancedInsights,
component: () => <GenericUpsellingPageLazy projectPLIs={projectPLIs} />,
pli: AppFeatureKey.advancedInsights,
component: () => <GenericUpsellingPageLazy requiredPLI={AppFeatureKey.advancedInsights} />,
},
];
const getUpsellingSections = (projectPLIs: SecurityProductLineIds): UpsellingSections => [
// Upsellings for sections, linked by arbitrary ids
export const upsellingSections: UpsellingSections = [
{
id: 'entity_analytics_panel',
feature: AppFeatureKey.advancedInsights,
component: () => <GenericUpsellingSectionLazy projectPLIs={projectPLIs} />,
pli: AppFeatureKey.advancedInsights,
component: () => <GenericUpsellingSectionLazy requiredPLI={AppFeatureKey.advancedInsights} />,
},
];

View file

@ -36,7 +36,7 @@ export class ServerlessSecurityPlugin
_core: CoreSetup,
setupDeps: ServerlessSecurityPluginSetupDependencies
): ServerlessSecurityPluginSetup {
registerUpsellings(setupDeps.securitySolution.upselling, this.config.productLineIds);
registerUpsellings(setupDeps.securitySolution.upselling, this.config.productTypes);
return {};
}

View file

@ -11,7 +11,7 @@ import type {
PluginStart as SecuritySolutionPluginStart,
} from '@kbn/security-solution-plugin/public';
import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public';
import type { SecurityProductLineIds } from '../common/config';
import type { SecurityProductTypes } from '../common/config';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessSecurityPluginSetup {}
@ -32,5 +32,5 @@ export interface ServerlessSecurityPluginStartDependencies {
}
export interface ServerlessSecurityPublicConfig {
productLineIds: SecurityProductLineIds;
productTypes: SecurityProductTypes;
}

View file

@ -7,17 +7,17 @@
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core/server';
import { productLineIds } from '../common/config';
import { productTypes } from '../common/config';
export const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
productLineIds,
productTypes,
});
export type ServerlessSecurityConfig = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<ServerlessSecurityConfig> = {
exposeToBrowser: {
productLineIds: true,
productTypes: true,
},
schema: configSchema,
};

View file

@ -38,9 +38,7 @@ export class ServerlessSecurityPlugin
const shouldRegister = pluginsSetup.essSecurity == null;
if (shouldRegister) {
pluginsSetup.securitySolution.setAppFeatures(
getProductAppFeatures(this.config.productLineIds)
);
pluginsSetup.securitySolution.setAppFeatures(getProductAppFeatures(this.config.productTypes));
}
return {};