[Cloud Security] Feature Flag Support for Cloud Security Posture Plugin (#205438)

## Summary

Summarize your PR. If it involves visual changes include a screenshot or
gif.

## Changes

* Adds `enableExperimental` to server `configSchema`
* Makes feature flags configurable via
`xpack.cloudSecurityPosture.enableExperimental` in `kibana.dev.yml`
* Implements `ExperimentFeatureService.get()` for accessing feature
flags
* Add passing `initliaterContext` to plugin in order to access our
plugin config

## Benefits

* Avoids circular dependency with Security Solution
`useIsExperimentalFeatureEnabled` and prop drilling feature flags from
Fleet plugin `PackagePolicyReplaceDefineStepExtensionComponentProps`
* Provides server-side configuration support
* Enables pre-release feature testing
* Creates centralized feature flag management

This allows controlled testing of new features before release through
configuration rather than code changes.

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Lola 2025-01-10 10:55:06 -05:00 committed by GitHub
parent 55390001ad
commit 473eb721bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 134 additions and 7 deletions

View file

@ -15,7 +15,7 @@ pageLoadAssetSize:
cloudExperiments: 109746
cloudFullStory: 18493
cloudLinks: 55984
cloudSecurityPosture: 19109
cloudSecurityPosture: 19270
console: 46091
contentManagement: 16254
controls: 60000

View file

@ -250,6 +250,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.cloud.performance_url (string?)',
'xpack.cloud.users_and_roles_url (string?)',
'xpack.cloud.projects_url (string?|never)',
'xpack.cloudSecurityPosture.enableExperimental (array?)',
// can't be used to infer urls or customer id from the outside
'xpack.cloud.serverless.project_id (string?)',
'xpack.cloud.serverless.project_name (string?)',

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface CSPUIConfigType {
enableExperimental: string[];
}
export type ExperimentalFeatures = { [K in keyof typeof allowedExperimentalValues]: boolean };
/**
* A list of allowed values that can be used in `xpack.cloud_security_posture.enableExperimental`.
* This object is then used to validate and parse the value entered.
*/
export const allowedExperimentalValues = Object.freeze({
/**
* Enables cloud Connectors for Cloud Security Posture
*/
cloudConnectorsEnabled: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;
type Mutable<T> = { -readonly [P in keyof T]: T[P] };
const allowedKeys = Object.keys(allowedExperimentalValues) as Readonly<ExperimentalConfigKeys>;
/**
* Parses the string value used in `xpack.cloud_security_posture.enableExperimental` kibana configuration,
* which should be a string of values delimited by a comma (`,`)
*
* @param configValue
* @throws SecuritySolutionInvalidExperimentalValue
*/
export const parseExperimentalConfigValue = (
configValue: string[]
): { features: ExperimentalFeatures; invalid: string[] } => {
const enabledFeatures: Mutable<Partial<ExperimentalFeatures>> = {};
const invalidKeys: string[] = [];
for (const value of configValue) {
if (!allowedKeys.includes(value as keyof ExperimentalFeatures)) {
invalidKeys.push(value);
} else {
enabledFeatures[value as keyof ExperimentalFeatures] = true;
}
}
return {
features: {
...allowedExperimentalValues,
...enabledFeatures,
},
invalid: invalidKeys,
};
};
export const getExperimentalAllowedValues = (): string[] => [...allowedKeys];

View file

@ -0,0 +1,30 @@
/*
* 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 { ExperimentalFeatures } from '../../common/experimental_features';
export class ExperimentalFeaturesService {
private static experimentalFeatures?: ExperimentalFeatures;
public static init({ experimentalFeatures }: { experimentalFeatures: ExperimentalFeatures }) {
this.experimentalFeatures = experimentalFeatures;
}
public static get(): ExperimentalFeatures {
if (!this.experimentalFeatures) {
this.throwUninitializedError();
}
return this.experimentalFeatures;
}
private static throwUninitializedError(): never {
throw new Error(
'Technical preview features services not initialized - are you trying to import this module from outside of the Security Solution app?'
);
}
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/public';
import { CspPlugin } from './plugin';
export type { CspSecuritySolutionContext } from './types';
export type { CloudSecurityPosturePageId } from './common/navigation/types';
@ -12,4 +13,5 @@ export { getSecuritySolutionLink } from './common/navigation/security_solution_l
export type { CspClientPluginSetup, CspClientPluginStart } from './types';
export const plugin = () => new CspPlugin();
export const plugin = (initializerContext: PluginInitializerContext) =>
new CspPlugin(initializerContext);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { lazy, Suspense } from 'react';
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
@ -16,6 +16,12 @@ import type { CspRouterProps } from './application/csp_router';
import type { CspClientPluginSetup, CspClientPluginStart, CspClientPluginSetupDeps } from './types';
import { CLOUD_SECURITY_POSTURE_PACKAGE_NAME } from '../common/constants';
import { SetupContext } from './application/setup_context';
import {
type CSPUIConfigType,
type ExperimentalFeatures,
parseExperimentalConfigValue,
} from '../common/experimental_features';
import { ExperimentalFeaturesService } from './common/experimental_features_service';
const LazyCspPolicyTemplateForm = lazy(
() => import('./components/fleet_extensions/policy_template_form')
@ -42,13 +48,22 @@ export class CspPlugin
>
{
private isCloudEnabled?: boolean;
private config: CSPUIConfigType;
private experimentalFeatures: ExperimentalFeatures;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<CSPUIConfigType>();
this.experimentalFeatures = parseExperimentalConfigValue(
this.config.enableExperimental || []
)?.features;
}
public setup(
_core: CoreSetup<CspClientPluginStartDeps, CspClientPluginStart>,
plugins: CspClientPluginSetupDeps
): CspClientPluginSetup {
this.isCloudEnabled = plugins.cloud.isCloudEnabled;
if (plugins.usageCollection) uiMetricService.setup(plugins.usageCollection);
// Return methods that should be available to other plugins
@ -56,6 +71,7 @@ export class CspPlugin
}
public start(core: CoreStart, plugins: CspClientPluginStartDeps): CspClientPluginStart {
ExperimentalFeaturesService.init({ experimentalFeatures: this.experimentalFeatures });
plugins.fleet.registerExtension({
package: CLOUD_SECURITY_POSTURE_PACKAGE_NAME,
view: 'package-policy-replace-define-step',

View file

@ -18,10 +18,28 @@ const configSchema = schema.object({
options: { defaultValue: schema.contextRef('serverless') },
}),
}),
/**
* For internal use. A list of string values (comma delimited) that will enable experimental
* type of functionality that is not yet released. Valid values for this settings need to
* be defined in:
* `x-pack/solutions/security/plugins/cloud_security_posture/common/experimental_features.ts`
* under the `allowedExperimentalValues` object
*
* @example
* xpack.cloudSecurityPosture.enableExperimental:
* - newFeatureA
* - newFeatureB
*/
enableExperimental: schema.arrayOf(schema.string(), {
defaultValue: () => [],
}),
});
export type CloudSecurityPostureConfig = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<CloudSecurityPostureConfig> = {
schema: configSchema,
exposeToBrowser: {
enableExperimental: true,
},
};

View file

@ -63,7 +63,7 @@ describe('createBenchmarkScoreIndex', () => {
it('should create index template the correct index patter, index name and default ingest pipeline but without lifecycle in serverless', async () => {
await createBenchmarkScoreIndex(
mockEsClient,
{ serverless: { enabled: true }, enabled: true },
{ serverless: { enabled: true }, enabled: true, enableExperimental: [] },
logger
);
expect(mockEsClient.indices.putIndexTemplate).toHaveBeenCalledTimes(1);
@ -87,7 +87,7 @@ describe('createBenchmarkScoreIndex', () => {
await createBenchmarkScoreIndex(
mockEsClient,
{ serverless: { enabled: true }, enabled: true },
{ serverless: { enabled: true }, enabled: true, enableExperimental: [] },
logger
);
expect(mockEsClient.indices.create).toHaveBeenCalledTimes(1);
@ -102,7 +102,7 @@ describe('createBenchmarkScoreIndex', () => {
await createBenchmarkScoreIndex(
mockEsClient,
{ serverless: { enabled: true }, enabled: true },
{ serverless: { enabled: true }, enabled: true, enableExperimental: [] },
logger
);
expect(mockEsClient.indices.create).toHaveBeenCalledTimes(0);