Enabled connector types API (#164689)

Resolves: #163751

This PR adds a new API to allow the other plugins to define an **enabled
connector types list** in the actions registry.
The list is used during the action execution to decide if the action
type is executable (enabled).
This decision logic sits on the existing two other checks: 

1- `isActionTypeEnabled` -> if the connector type is in the
`enabledActionTypes` list in the config
2- `isLicenseValidForActionType` -> if the connector type is allowed for
the active license

As the only user of this feature is just security-solutions for now, we
decided to allow the list to be set only once.

<img width="890" alt="Screenshot 2023-08-31 at 12 00 10"
src="896ab104-6f75-4eee-8f19-9f2eb04bb149">
<img width="592" alt="Screenshot 2023-08-31 at 12 13 38"
src="533a1cfd-947a-4506-b078-149780346088">
<img width="1513" alt="Screenshot 2023-08-31 at 12 00 01"
src="91169dfe-b7f8-4fef-b734-56857a75dbdc">

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ersin Erdal 2023-09-05 10:15:21 +02:00 committed by GitHub
parent 0f40b6dbc1
commit 528884c5f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 240 additions and 12 deletions

View file

@ -21,7 +21,8 @@
"usageCollection",
"spaces",
"security",
"monitoringCollection"
"monitoringCollection",
"serverless"
],
"extraPublicDirs": [
"common"

View file

@ -64,6 +64,15 @@ const connectorTypeSchema = schema.object({
maxAttempts: schema.maybe(schema.number({ min: MIN_MAX_ATTEMPTS, max: MAX_MAX_ATTEMPTS })),
});
// We leverage enabledActionTypes list by allowing the other plugins to overwrite it by using "setEnabledConnectorTypes" in the plugin setup.
// The list can be overwritten only if it's not already been set in the config.
const enabledConnectorTypesSchema = schema.arrayOf(
schema.oneOf([schema.string(), schema.literal(EnabledActionTypes.Any)]),
{
defaultValue: [AllowedHosts.Any],
}
);
export const configSchema = schema.object({
allowedHosts: schema.arrayOf(
schema.oneOf([schema.string({ hostname: true }), schema.literal(AllowedHosts.Any)]),
@ -71,12 +80,7 @@ export const configSchema = schema.object({
defaultValue: [AllowedHosts.Any],
}
),
enabledActionTypes: schema.arrayOf(
schema.oneOf([schema.string(), schema.literal(EnabledActionTypes.Any)]),
{
defaultValue: [AllowedHosts.Any],
}
),
enabledActionTypes: enabledConnectorTypesSchema,
preconfiguredAlertHistoryEsIndex: schema.boolean({ defaultValue: false }),
preconfigured: schema.recordOf(schema.string(), preconfiguredActionSchema, {
defaultValue: {},
@ -129,6 +133,7 @@ export const configSchema = schema.object({
});
export type ActionsConfig = TypeOf<typeof configSchema>;
export type EnabledConnectorTypes = TypeOf<typeof enabledConnectorTypesSchema>;
// It would be nicer to add the proxyBypassHosts / proxyOnlyHosts restriction on
// simultaneous usage in the config validator directly, but there's no good way to express

View file

@ -31,6 +31,7 @@ const createSetupMock = () => {
getCaseConnectorClass: jest.fn(),
getActionsHealth: jest.fn(),
getActionsConfigurationUtilities: jest.fn(),
setEnabledConnectorTypes: jest.fn(),
};
return mock;
};

View file

@ -348,6 +348,163 @@ describe('Actions Plugin', () => {
expect(pluginSetup.isPreconfiguredConnector('anotherConnectorId')).toEqual(false);
});
});
describe('setEnabledConnectorTypes (works only on serverless)', () => {
function setup(config: ActionsConfig) {
context = coreMock.createPluginInitializerContext<ActionsConfig>(config);
plugin = new ActionsPlugin(context);
coreSetup = coreMock.createSetup();
pluginsSetup = {
taskManager: taskManagerMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(),
licensing: licensingMock.createSetup(),
eventLog: eventLogMock.createSetup(),
usageCollection: usageCollectionPluginMock.createSetupContract(),
features: featuresPluginMock.createSetup(),
serverless: {},
};
}
it('should set connector type enabled', async () => {
setup(getConfig());
// coreMock.createSetup doesn't support Plugin generics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup);
const coreStart = coreMock.createStart();
const pluginsStart = {
licensing: licensingMock.createStart(),
taskManager: taskManagerMock.createStart(),
encryptedSavedObjects: encryptedSavedObjectsMock.createStart(),
eventLog: eventLogMock.createStart(),
};
const pluginStart = plugin.start(coreStart, pluginsStart);
pluginSetup.registerType({
id: '.server-log',
name: 'Server log',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
pluginSetup.registerType({
id: '.slack',
name: 'Slack',
minimumLicenseRequired: 'gold',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
pluginSetup.setEnabledConnectorTypes(['.server-log']);
expect(pluginStart.isActionTypeEnabled('.server-log')).toBeTruthy();
expect(pluginStart.isActionTypeEnabled('.slack')).toBeFalsy();
});
it('should set all the connector types enabled when null or ["*"] passed', async () => {
setup(getConfig());
// coreMock.createSetup doesn't support Plugin generics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup);
const coreStart = coreMock.createStart();
const pluginsStart = {
licensing: licensingMock.createStart(),
taskManager: taskManagerMock.createStart(),
encryptedSavedObjects: encryptedSavedObjectsMock.createStart(),
eventLog: eventLogMock.createStart(),
};
const pluginStart = plugin.start(coreStart, pluginsStart);
pluginSetup.registerType({
id: '.server-log',
name: 'Server log',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
pluginSetup.registerType({
id: '.index',
name: 'Index',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
pluginSetup.setEnabledConnectorTypes(['*']);
expect(pluginStart.isActionTypeEnabled('.server-log')).toBeTruthy();
expect(pluginStart.isActionTypeEnabled('.index')).toBeTruthy();
});
it('should set all the connector types disabled when [] passed', async () => {
setup(getConfig());
// coreMock.createSetup doesn't support Plugin generics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup);
const coreStart = coreMock.createStart();
const pluginsStart = {
licensing: licensingMock.createStart(),
taskManager: taskManagerMock.createStart(),
encryptedSavedObjects: encryptedSavedObjectsMock.createStart(),
eventLog: eventLogMock.createStart(),
};
const pluginStart = plugin.start(coreStart, pluginsStart);
pluginSetup.registerType({
id: '.server-log',
name: 'Server log',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
pluginSetup.registerType({
id: '.index',
name: 'Index',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
pluginSetup.setEnabledConnectorTypes([]);
expect(pluginStart.isActionTypeEnabled('.server-log')).toBeFalsy();
expect(pluginStart.isActionTypeEnabled('.index')).toBeFalsy();
});
it('should throw if the enabledActionTypes is already set by the config', async () => {
setup({ ...getConfig(), enabledActionTypes: ['.email'] });
// coreMock.createSetup doesn't support Plugin generics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup);
expect(() => pluginSetup.setEnabledConnectorTypes(['.index'])).toThrow(
"Enabled connector types can be set only if they haven't already been set in the config"
);
});
});
});
describe('start()', () => {
@ -396,6 +553,38 @@ describe('Actions Plugin', () => {
};
});
it('should throw when there is an invalid connector type in enabledActionTypes', async () => {
const pluginSetup = await plugin.setup(coreSetup, {
...pluginsSetup,
encryptedSavedObjects: {
...pluginsSetup.encryptedSavedObjects,
canEncrypt: true,
},
serverless: {},
});
pluginSetup.registerType({
id: '.server-log',
name: 'Server log',
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['alerting'],
validate: {
config: { schema: schema.object({}) },
secrets: { schema: schema.object({}) },
params: { schema: schema.object({}) },
},
executor,
});
pluginSetup.setEnabledConnectorTypes(['.server-log', 'non-existing']);
await expect(async () =>
plugin.start(coreStart, { ...pluginsStart, serverless: {} })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Action type \\"non-existing\\" is not registered."`
);
});
describe('getActionsClientWithRequest()', () => {
it('should not throw error when ESO plugin has encryption key', async () => {
await plugin.setup(coreSetup, {

View file

@ -40,7 +40,8 @@ import {
} from '@kbn/event-log-plugin/server';
import { MonitoringCollectionSetup } from '@kbn/monitoring-collection-plugin/server';
import { ActionsConfig, getValidatedConfig } from './config';
import { ServerlessPluginSetup } from '@kbn/serverless/server';
import { ActionsConfig, AllowedHosts, EnabledConnectorTypes, getValidatedConfig } from './config';
import { resolveCustomHosts } from './lib/custom_host_settings';
import { ActionsClient } from './actions_client/actions_client';
import { ActionTypeRegistry } from './action_type_registry';
@ -100,10 +101,8 @@ import { createSubActionConnectorFramework } from './sub_action_framework';
import { IServiceAbstract, SubActionConnectorType } from './sub_action_framework/types';
import { SubActionConnector } from './sub_action_framework/sub_action_connector';
import { CaseConnector } from './sub_action_framework/case';
import {
type IUnsecuredActionsClient,
UnsecuredActionsClient,
} from './unsecured_actions_client/unsecured_actions_client';
import type { IUnsecuredActionsClient } from './unsecured_actions_client/unsecured_actions_client';
import { UnsecuredActionsClient } from './unsecured_actions_client/unsecured_actions_client';
import { createBulkUnsecuredExecutionEnqueuerFunction } from './create_unsecured_execute_function';
import { createSystemConnectors } from './create_system_actions';
@ -130,6 +129,7 @@ export interface PluginSetupContract {
getCaseConnectorClass: <Config, Secrets>() => IServiceAbstract<Config, Secrets>;
getActionsHealth: () => { hasPermanentEncryptionKey: boolean };
getActionsConfigurationUtilities: () => ActionsConfigurationUtilities;
setEnabledConnectorTypes: (connectorTypes: EnabledConnectorTypes) => void;
}
export interface PluginStartContract {
@ -169,6 +169,7 @@ export interface ActionsPluginsSetup {
features: FeaturesPluginSetup;
spaces?: SpacesPluginSetup;
monitoringCollection?: MonitoringCollectionSetup;
serverless?: ServerlessPluginSetup;
}
export interface ActionsPluginsStart {
@ -178,6 +179,7 @@ export interface ActionsPluginsStart {
eventLog: IEventLogClientService;
spaces?: SpacesPluginStart;
security?: SecurityPluginStart;
serverless?: ServerlessPluginSetup;
}
const includedHiddenTypes = [
@ -375,6 +377,20 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
};
},
getActionsConfigurationUtilities: () => actionsConfigUtils,
setEnabledConnectorTypes: (connectorTypes) => {
if (
!!plugins.serverless &&
this.actionsConfig.enabledActionTypes.length === 1 &&
this.actionsConfig.enabledActionTypes[0] === AllowedHosts.Any
) {
this.actionsConfig.enabledActionTypes.pop();
this.actionsConfig.enabledActionTypes.push(...connectorTypes);
} else {
throw new Error(
"Enabled connector types can be set only if they haven't already been set in the config"
);
}
},
};
}
@ -542,6 +558,8 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
});
}
this.validateEnabledConnectorTypes(plugins);
return {
isActionTypeEnabled: (id, options = { notifyUsage: false }) => {
return this.actionTypeRegistry!.isActionTypeEnabled(id, options);
@ -695,6 +713,19 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
};
};
private validateEnabledConnectorTypes = (plugins: ActionsPluginsStart) => {
if (
!!plugins.serverless &&
this.actionsConfig.enabledActionTypes.length > 0 &&
this.actionsConfig.enabledActionTypes[0] !== AllowedHosts.Any
) {
this.actionsConfig.enabledActionTypes.forEach((connectorType) => {
// Throws error if action type doesn't exist
this.actionTypeRegistry?.get(connectorType);
});
}
};
public stop() {
if (this.licenseState) {
this.licenseState.clean();

View file

@ -43,6 +43,7 @@
"@kbn/core-saved-objects-api-server-mocks",
"@kbn/core-elasticsearch-server-mocks",
"@kbn/core-logging-server-mocks",
"@kbn/serverless"
],
"exclude": [
"target/**/*",