[8.10] [RAM] Use ruletype to determine alert indices (#163574) (#164380)

# Backport

This will backport the following commits from `main` to `8.10`:
- [[RAM] Use ruletype to determine alert indices
(#163574)](https://github.com/elastic/kibana/pull/163574)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Xavier
Mouligneau","email":"xavier.mouligneau@elastic.co"},"sourceCommit":{"committedDate":"2023-08-21T21:52:36Z","message":"[RAM]
Use ruletype to determine alert indices (#163574)\n\n##
Summary\r\n\r\nWe were using the feature Id to determine the alert
indices, but we\r\nrealized that we should use the rule type id instead.
Meaning that we\r\ncheck which rule type does the user have access and
then we get the\r\nindices related to this rule type.\r\n\r\nWe also
took advantage of the new suggestion abstraction of the search\r\nbar
components to remove the toaster of hell
->\r\nhttps://github.com/elastic/kibana/issues/163003\r\n\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"d7bf7efcdfb46ef8d44e9b554d670e13010bfab6","branchLabelMapping":{"^v8.11.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:skip","impact:high","Team:ResponseOps","v8.10.0","v8.11.0"],"number":163574,"url":"https://github.com/elastic/kibana/pull/163574","mergeCommit":{"message":"[RAM]
Use ruletype to determine alert indices (#163574)\n\n##
Summary\r\n\r\nWe were using the feature Id to determine the alert
indices, but we\r\nrealized that we should use the rule type id instead.
Meaning that we\r\ncheck which rule type does the user have access and
then we get the\r\nindices related to this rule type.\r\n\r\nWe also
took advantage of the new suggestion abstraction of the search\r\nbar
components to remove the toaster of hell
->\r\nhttps://github.com/elastic/kibana/issues/163003\r\n\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"d7bf7efcdfb46ef8d44e9b554d670e13010bfab6"}},"sourceBranch":"main","suggestedTargetBranches":["8.10"],"targetPullRequestStates":[{"branch":"8.10","label":"v8.10.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.11.0","labelRegex":"^v8.11.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/163574","number":163574,"mergeCommit":{"message":"[RAM]
Use ruletype to determine alert indices (#163574)\n\n##
Summary\r\n\r\nWe were using the feature Id to determine the alert
indices, but we\r\nrealized that we should use the rule type id instead.
Meaning that we\r\ncheck which rule type does the user have access and
then we get the\r\nindices related to this rule type.\r\n\r\nWe also
took advantage of the new suggestion abstraction of the search\r\nbar
components to remove the toaster of hell
->\r\nhttps://github.com/elastic/kibana/issues/163003\r\n\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"d7bf7efcdfb46ef8d44e9b554d670e13010bfab6"}}]}]
BACKPORT-->

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
This commit is contained in:
Kibana Machine 2023-08-21 18:01:12 -04:00 committed by GitHub
parent 723cdf73c5
commit e38772f48b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 765 additions and 304 deletions

View file

@ -16,6 +16,7 @@ const createAlertingAuthorizationMock = () => {
ensureAuthorized: jest.fn(),
filterByRuleTypeAuthorization: jest.fn(),
getAuthorizationFilter: jest.fn(),
getAuthorizedRuleTypes: jest.fn(),
getFindAuthorizationFilter: jest.fn(),
getAugmentedRuleTypesWithAuthorization: jest.fn(),
getSpaceId: jest.fn(),

View file

@ -52,27 +52,28 @@ function mockSecurity() {
return { authorization };
}
function mockFeature(appName: string, typeName?: string) {
function mockFeature(appName: string, typeName?: string | string[]) {
const typeNameArray = typeName ? (Array.isArray(typeName) ? typeName : [typeName]) : undefined;
return new KibanaFeature({
id: appName,
name: appName,
app: [],
category: { id: 'foo', label: 'foo' },
...(typeName
...(typeNameArray
? {
alerting: [typeName],
alerting: typeNameArray,
}
: {}),
privileges: {
all: {
...(typeName
...(typeNameArray
? {
alerting: {
rule: {
all: [typeName],
all: typeNameArray,
},
alert: {
all: [typeName],
all: typeNameArray,
},
},
}
@ -84,14 +85,14 @@ function mockFeature(appName: string, typeName?: string) {
ui: [],
},
read: {
...(typeName
...(typeNameArray
? {
alerting: {
rule: {
read: [typeName],
read: typeNameArray,
},
alert: {
read: [typeName],
read: typeNameArray,
},
},
}
@ -815,6 +816,12 @@ describe('AlertingAuthorization', () => {
ensureRuleTypeIsAuthorized('someMadeUpType', 'myApp', 'rule');
});
test('creates a filter based on the privileged types', async () => {
features.getKibanaFeatures.mockReturnValue([
mockFeature('myApp', ['myAppAlertType', 'mySecondAppAlertType']),
mockFeature('alerts', 'myOtherAppAlertType'),
myOtherAppFeature,
myAppWithSubFeature,
]);
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<
ReturnType<typeof authorization.checkPrivilegesDynamicallyWithRequest>
@ -846,7 +853,7 @@ describe('AlertingAuthorization', () => {
).filter
).toEqual(
fromKueryExpression(
`((path.to.rule_type_id:myAppAlertType and consumer-field:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule_type_id:myOtherAppAlertType and consumer-field:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule_type_id:mySecondAppAlertType and consumer-field:(alerts or myApp or myOtherApp or myAppWithSubFeature)))`
`((path.to.rule_type_id:myAppAlertType and consumer-field:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule_type_id:mySecondAppAlertType and consumer-field:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule_type_id:myOtherAppAlertType and consumer-field:(alerts or myApp or myOtherApp or myAppWithSubFeature)))`
)
);
});
@ -894,6 +901,10 @@ describe('AlertingAuthorization', () => {
);
});
test('creates an `ensureRuleTypeIsAuthorized` function which throws if type is unauthorized', async () => {
features.getKibanaFeatures.mockReturnValue([
mockFeature('myApp', ['myOtherAppAlertType', 'myAppAlertType']),
mockFeature('myOtherApp', ['myOtherAppAlertType', 'myAppAlertType']),
]);
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<
ReturnType<typeof authorization.checkPrivilegesDynamicallyWithRequest>
@ -954,6 +965,10 @@ describe('AlertingAuthorization', () => {
);
});
test('creates an `ensureRuleTypeIsAuthorized` function which is no-op if type is authorized', async () => {
features.getKibanaFeatures.mockReturnValue([
mockFeature('myApp', ['myOtherAppAlertType', 'myAppAlertType']),
mockFeature('myOtherApp', 'myAppAlertType'),
]);
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<
ReturnType<typeof authorization.checkPrivilegesDynamicallyWithRequest>
@ -1012,6 +1027,10 @@ describe('AlertingAuthorization', () => {
}).not.toThrow();
});
test('creates an `logSuccessfulAuthorization` function which logs every authorized type', async () => {
features.getKibanaFeatures.mockReturnValue([
mockFeature('myApp', ['myOtherAppAlertType', 'myAppAlertType', 'mySecondAppAlertType']),
mockFeature('myOtherApp', ['mySecondAppAlertType', 'myAppAlertType']),
]);
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<
ReturnType<typeof authorization.checkPrivilegesDynamicallyWithRequest>
@ -1140,8 +1159,19 @@ describe('AlertingAuthorization', () => {
enabledInLicense: true,
};
const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType]);
beforeEach(() => {
features.getKibanaFeatures.mockReturnValue([
mockFeature('myApp', ['myOtherAppAlertType', 'myAppAlertType']),
mockFeature('myOtherApp', ['myAppAlertType', 'myOtherAppAlertType']),
]);
});
test('augments a list of types with all features when there is no authorization api', async () => {
features.getKibanaFeatures.mockReturnValue([
myAppFeature,
myOtherAppFeature,
myAppWithSubFeature,
myFeatureWithoutAlerting,
]);
const alertAuthorization = new AlertingAuthorization({
request,
ruleTypeRegistry,
@ -1670,7 +1700,9 @@ describe('AlertingAuthorization', () => {
isExportable: true,
};
const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType, mySecondAppAlertType]);
beforeEach(() => {
features.getKibanaFeatures.mockReturnValue([mockFeature('myApp', ['myOtherAppAlertType'])]);
});
test('it returns authorized rule types given a set of feature ids', async () => {
const { authorization } = mockSecurity();
const checkPrivileges: jest.MockedFunction<

View file

@ -92,7 +92,7 @@ export class AlertingAuthorization {
private readonly featuresIds: Promise<Set<string>>;
private readonly allPossibleConsumers: Promise<AuthorizedConsumers>;
private readonly spaceId: string | undefined;
private readonly features: FeaturesPluginStart;
constructor({
ruleTypeRegistry,
request,
@ -104,7 +104,7 @@ export class AlertingAuthorization {
this.request = request;
this.authorization = authorization;
this.ruleTypeRegistry = ruleTypeRegistry;
this.features = features;
this.spaceId = getSpaceId(request);
this.featuresIds = getSpace(request)
@ -262,6 +262,19 @@ export class AlertingAuthorization {
return this.getAuthorizationFilter(authorizationEntity, filterOpts, ReadOperations.Find);
}
public async getAuthorizedRuleTypes(
authorizationEntity: AlertingAuthorizationEntity,
featuresIds?: Set<string>
): Promise<RegistryAlertTypeWithAuth[]> {
const { authorizedRuleTypes } = await this.augmentRuleTypesWithAuthorization(
this.ruleTypeRegistry.list(),
[ReadOperations.Find],
authorizationEntity,
featuresIds
);
return Array.from(authorizedRuleTypes);
}
public async getAuthorizationFilter(
authorizationEntity: AlertingAuthorizationEntity,
filterOpts: AlertingAuthorizationFilterOpts,
@ -355,28 +368,44 @@ export class AlertingAuthorization {
);
// add an empty `authorizedConsumers` array on each ruleType
const ruleTypesWithAuthorization = this.augmentWithAuthorizedConsumers(ruleTypes, {});
const ruleTypesWithAuthorization = Array.from(
this.augmentWithAuthorizedConsumers(ruleTypes, {})
);
const ruleTypesAuthorized: Map<string, RegistryRuleType> = new Map();
// map from privilege to ruleType which we can refer back to when analyzing the result
// of checkPrivileges
const privilegeToRuleType = new Map<
string,
[RegistryAlertTypeWithAuth, string, HasPrivileges, IsAuthorizedAtProducerLevel]
>();
// as we can't ask ES for the user's individual privileges we need to ask for each feature
// and ruleType in the system whether this user has this privilege
for (const ruleType of ruleTypesWithAuthorization) {
for (const feature of fIds) {
for (const operation of operations) {
privilegeToRuleType.set(
this.authorization!.actions.alerting.get(
ruleType.id,
feature,
authorizationEntity,
operation
),
[ruleType, feature, hasPrivilegeByOperation(operation), ruleType.producer === feature]
);
for (const feature of fIds) {
const featureDef = this.features
.getKibanaFeatures()
.find((kFeature) => kFeature.id === feature);
for (const ruleTypeId of featureDef?.alerting ?? []) {
const ruleTypeAuth = ruleTypesWithAuthorization.find((rtwa) => rtwa.id === ruleTypeId);
if (ruleTypeAuth) {
if (!ruleTypesAuthorized.has(ruleTypeId)) {
const { authorizedConsumers, hasAlertsMappings, hasFieldsForAAD, ...ruleType } =
ruleTypeAuth;
ruleTypesAuthorized.set(ruleTypeId, ruleType);
}
for (const operation of operations) {
privilegeToRuleType.set(
this.authorization!.actions.alerting.get(
ruleTypeId,
feature,
authorizationEntity,
operation
),
[
ruleTypeAuth,
feature,
hasPrivilegeByOperation(operation),
ruleTypeAuth.producer === feature,
]
);
}
}
}
}
@ -388,30 +417,36 @@ export class AlertingAuthorization {
return {
username,
hasAllRequested,
authorizedRuleTypes: hasAllRequested
? // has access to all features
this.augmentWithAuthorizedConsumers(ruleTypes, await this.allPossibleConsumers)
: // only has some of the required privileges
privileges.kibana.reduce((authorizedRuleTypes, { authorized, privilege }) => {
if (authorized && privilegeToRuleType.has(privilege)) {
const [ruleType, feature, hasPrivileges, isAuthorizedAtProducerLevel] =
privilegeToRuleType.get(privilege)!;
ruleType.authorizedConsumers[feature] = mergeHasPrivileges(
hasPrivileges,
ruleType.authorizedConsumers[feature]
);
authorizedRuleTypes:
hasAllRequested && featuresIds === undefined
? // has access to all features
this.augmentWithAuthorizedConsumers(
new Set(ruleTypesAuthorized.values()),
await this.allPossibleConsumers
)
: // only has some of the required privileges
privileges.kibana.reduce((authorizedRuleTypes, { authorized, privilege }) => {
if (authorized && privilegeToRuleType.has(privilege)) {
const [ruleType, feature, hasPrivileges, isAuthorizedAtProducerLevel] =
privilegeToRuleType.get(privilege)!;
if (fIds.has(feature)) {
ruleType.authorizedConsumers[feature] = mergeHasPrivileges(
hasPrivileges,
ruleType.authorizedConsumers[feature]
);
if (isAuthorizedAtProducerLevel) {
// granting privileges under the producer automatically authorized the Rules Management UI as well
ruleType.authorizedConsumers[ALERTS_FEATURE_ID] = mergeHasPrivileges(
hasPrivileges,
ruleType.authorizedConsumers[ALERTS_FEATURE_ID]
);
if (isAuthorizedAtProducerLevel) {
// granting privileges under the producer automatically authorized the Rules Management UI as well
ruleType.authorizedConsumers[ALERTS_FEATURE_ID] = mergeHasPrivileges(
hasPrivileges,
ruleType.authorizedConsumers[ALERTS_FEATURE_ID]
);
}
authorizedRuleTypes.add(ruleType);
}
}
authorizedRuleTypes.add(ruleType);
}
return authorizedRuleTypes;
}, new Set<RegistryAlertTypeWithAuth>()),
return authorizedRuleTypes;
}, new Set<RegistryAlertTypeWithAuth>()),
};
} else {
return {

View file

@ -0,0 +1,112 @@
/*
* 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 { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
import { ConstructorOptions, RuleTypeRegistry } from '../rule_type_registry';
import { TaskRunnerFactory } from '../task_runner/task_runner_factory';
import { ILicenseState } from './license_state';
import { licenseStateMock } from './license_state.mock';
import { schema } from '@kbn/config-schema';
import { createGetAlertIndicesAliasFn } from './create_get_alert_indices_alias';
describe('createGetAlertIndicesAliasFn', () => {
const logger = loggingSystemMock.create().get();
const mockedLicenseState: jest.Mocked<ILicenseState> = licenseStateMock.create();
const taskManager = taskManagerMock.createSetup();
const inMemoryMetrics = inMemoryMetricsMock.create();
const ruleTypeRegistryParams: ConstructorOptions = {
logger,
taskManager,
taskRunnerFactory: new TaskRunnerFactory(),
alertsService: null,
licenseState: mockedLicenseState,
licensing: licensingMock.createSetup(),
minimumScheduleInterval: { value: '1m', enforce: false },
inMemoryMetrics,
};
const registry = new RuleTypeRegistry(ruleTypeRegistryParams);
registry.register({
id: 'test',
name: 'Test',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
producer: 'alerts',
alerts: {
context: 'test',
mappings: { fieldMap: { field: { type: 'keyword', required: false } } },
},
validate: {
params: { validate: (params) => params },
},
});
registry.register({
id: 'spaceAware',
name: 'Space Aware',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
producer: 'alerts',
alerts: {
context: 'spaceAware',
isSpaceAware: true,
mappings: { fieldMap: { field: { type: 'keyword', required: false } } },
},
validate: {
params: { validate: (params) => params },
},
});
registry.register({
id: 'foo',
name: 'Foo',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
executor: jest.fn(),
producer: 'alerts',
validate: {
params: schema.any(),
},
});
const getAlertIndicesAlias = createGetAlertIndicesAliasFn(registry);
test('getAlertIndicesAlias for the rule type with alert context', () => {
expect(getAlertIndicesAlias(['test'])).toEqual(['.alerts-test.alerts-default']);
});
test('getAlertIndicesAlias for the rule type with alert context with space I', () => {
expect(getAlertIndicesAlias(['spaceAware'], 'space-1')).toEqual([
'.alerts-spaceAware.alerts-space-1',
]);
});
test('getAlertIndicesAlias for the rule type with NO alert context', () => {
expect(getAlertIndicesAlias(['foo'])).toEqual([]);
});
});

View file

@ -0,0 +1,29 @@
/*
* 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_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';
import { getIndexTemplateAndPattern } from '../alerts_service/resource_installer_utils';
import { RuleTypeRegistry } from '../rule_type_registry';
export type GetAlertIndicesAlias = (rulesTypes: string[], spaceId?: string) => string[];
export function createGetAlertIndicesAliasFn(ruleTypeRegistry: RuleTypeRegistry) {
return (rulesTypes: string[], spaceId?: string): string[] => {
const aliases = new Set<string>();
rulesTypes.forEach((ruleTypeId) => {
const ruleType = ruleTypeRegistry.get(ruleTypeId);
if (ruleType.alerts?.context) {
const indexTemplateAndPattern = getIndexTemplateAndPattern({
context: ruleType.alerts?.context,
namespace: ruleType.alerts?.isSpaceAware && spaceId ? spaceId : DEFAULT_NAMESPACE_STRING,
});
aliases.add(indexTemplateAndPattern.alias);
}
});
return Array.from(aliases);
};
}

View file

@ -46,3 +46,5 @@ export { determineAlertsToReturn } from './determine_alerts_to_return';
export { updateFlappingHistory, isFlapping } from './flapping_utils';
export { getAlertsForNotification } from './get_alerts_for_notification';
export { trimRecoveredAlerts } from './trim_recovered_alerts';
export { createGetAlertIndicesAliasFn } from './create_get_alert_indices_alias';
export type { GetAlertIndicesAlias } from './create_get_alert_indices_alias';

View file

@ -59,6 +59,7 @@ const createStartMock = () => {
listTypes: jest.fn(),
getType: jest.fn(),
getAllTypes: jest.fn(),
getAlertIndicesAlias: jest.fn(),
getAlertingAuthorizationWithRequest: jest.fn(),
getRulesClientWithRequest: jest.fn().mockResolvedValue(rulesClientMock.create()),
getFrameworkHealth: jest.fn(),

View file

@ -97,6 +97,7 @@ import {
} from './alerts_service';
import { rulesSettingsFeature } from './rules_settings_feature';
import { maintenanceWindowFeature } from './maintenance_window_feature';
import { createGetAlertIndicesAliasFn, GetAlertIndicesAlias } from './lib';
export const EVENT_LOG_PROVIDER = 'alerting';
export const EVENT_LOG_ACTIONS = {
@ -145,6 +146,7 @@ export interface PluginStartContract {
getAllTypes: RuleTypeRegistry['getAllTypes'];
getType: RuleTypeRegistry['get'];
getAlertIndicesAlias: GetAlertIndicesAlias;
getRulesClientWithRequest(request: KibanaRequest): RulesClientApi;
@ -345,6 +347,7 @@ export class AlertingPlugin {
router,
licenseState: this.licenseState,
usageCounter: this.usageCounter,
getAlertIndicesAlias: createGetAlertIndicesAliasFn(this.ruleTypeRegistry!),
encryptedSavedObjects: plugins.encryptedSavedObjects,
config$: plugins.unifiedSearch.autocomplete.getInitializerContextConfig().create(),
});
@ -556,6 +559,7 @@ export class AlertingPlugin {
listTypes: ruleTypeRegistry!.list.bind(this.ruleTypeRegistry!),
getType: ruleTypeRegistry!.get.bind(this.ruleTypeRegistry),
getAllTypes: ruleTypeRegistry!.getAllTypes.bind(this.ruleTypeRegistry!),
getAlertIndicesAlias: createGetAlertIndicesAliasFn(this.ruleTypeRegistry!),
getAlertingAuthorizationWithRequest,
getRulesClientWithRequest,
getFrameworkHealth: async () =>

View file

@ -10,7 +10,7 @@ import { UsageCounter } from '@kbn/usage-collection-plugin/server';
import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server';
import type { ConfigSchema } from '@kbn/unified-search-plugin/config';
import { Observable } from 'rxjs';
import { ILicenseState } from '../lib';
import { GetAlertIndicesAlias, ILicenseState } from '../lib';
import { defineLegacyRoutes } from './legacy';
import { AlertingRequestHandlerContext } from '../types';
import { createRuleRoute } from './rule/apis/create';
@ -56,20 +56,29 @@ import { findMaintenanceWindowsRoute } from './maintenance_window/find_maintenan
import { archiveMaintenanceWindowRoute } from './maintenance_window/archive_maintenance_window';
import { finishMaintenanceWindowRoute } from './maintenance_window/finish_maintenance_window';
import { activeMaintenanceWindowsRoute } from './maintenance_window/active_maintenance_windows';
import { registerValueSuggestionsRoute } from './suggestions/values_suggestion_rules';
import { registerRulesValueSuggestionsRoute } from './suggestions/values_suggestion_rules';
import { registerFieldsRoute } from './suggestions/fields_rules';
import { bulkGetMaintenanceWindowRoute } from './maintenance_window/bulk_get_maintenance_windows';
import { registerAlertsValueSuggestionsRoute } from './suggestions/values_suggestion_alerts';
export interface RouteOptions {
router: IRouter<AlertingRequestHandlerContext>;
licenseState: ILicenseState;
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
getAlertIndicesAlias?: GetAlertIndicesAlias;
usageCounter?: UsageCounter;
config$?: Observable<ConfigSchema>;
}
export function defineRoutes(opts: RouteOptions) {
const { router, licenseState, encryptedSavedObjects, usageCounter, config$ } = opts;
const {
router,
licenseState,
encryptedSavedObjects,
usageCounter,
config$,
getAlertIndicesAlias,
} = opts;
defineLegacyRoutes(opts);
createRuleRoute(opts);
@ -116,7 +125,8 @@ export function defineRoutes(opts: RouteOptions) {
archiveMaintenanceWindowRoute(router, licenseState);
finishMaintenanceWindowRoute(router, licenseState);
activeMaintenanceWindowsRoute(router, licenseState);
registerValueSuggestionsRoute(router, licenseState, config$!);
registerAlertsValueSuggestionsRoute(router, licenseState, config$!, getAlertIndicesAlias);
registerRulesValueSuggestionsRoute(router, licenseState, config$!);
registerFieldsRoute(router, licenseState);
bulkGetMaintenanceWindowRoute(router, licenseState);
}

View file

@ -0,0 +1,89 @@
/*
* 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 { httpServerMock, httpServiceMock } from '@kbn/core-http-server-mocks';
import type { ConfigSchema } from '@kbn/unified-search-plugin/config';
import { dataPluginMock } from '@kbn/unified-search-plugin/server/mocks';
import { termsAggSuggestions } from '@kbn/unified-search-plugin/server/autocomplete/terms_agg';
import { Observable } from 'rxjs';
import { licenseStateMock } from '../../lib/license_state.mock';
import { rulesClientMock } from '../../rules_client.mock';
import { mockHandlerArguments } from '../_mock_handler_arguments';
import { registerAlertsValueSuggestionsRoute } from './values_suggestion_alerts';
jest.mock('@kbn/unified-search-plugin/server/autocomplete/terms_agg', () => {
return {
termsAggSuggestions: jest.fn(),
};
});
const termsAggSuggestionsMock = termsAggSuggestions as jest.Mock;
jest.mock('../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
describe('registerAlertsValueSuggestionsRoute', () => {
const rulesClient = rulesClientMock.create();
let config$: Observable<ConfigSchema>;
beforeEach(() => {
termsAggSuggestionsMock.mockClear();
rulesClient.getSpaceId.mockReturnValue('space-x');
config$ = dataPluginMock
.createSetupContract()
.autocomplete.getInitializerContextConfig()
.create();
});
test('happy path route registered', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
const getAlertIndicesAliasMock = jest.fn().mockReturnValue(['alert-index']);
registerAlertsValueSuggestionsRoute(router, licenseState, config$, getAlertIndicesAliasMock);
const [config, handler] = router.post.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerts/suggestions/values"`);
const mockRequest = httpServerMock.createKibanaRequest<never, never, never>({
body: {
field: 'alert.tags',
query: 'test-query',
filters: 'test-filters',
fieldMeta: 'test-field-meta',
},
});
const [context, req, res] = mockHandlerArguments({ rulesClient }, mockRequest, ['ok']);
await handler(
{
...context,
core: { elasticsearch: { client: { asInternalUser: {} } }, savedObjects: { client: {} } },
},
req,
res
);
expect(rulesClient.getAuthorization).toHaveBeenCalledTimes(1);
expect(rulesClient.getSpaceId).toHaveBeenCalledTimes(1);
expect(termsAggSuggestionsMock).toHaveBeenNthCalledWith(
1,
expect.any(Object),
expect.any(Object),
expect.any(Object),
'alert-index',
'alert.tags',
'test-query',
[{ term: { 'kibana.space_ids': 'space-x' } }],
'test-field-meta',
expect.any(Object)
);
expect(res.ok).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,128 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { IRouter } from '@kbn/core/server';
import { firstValueFrom, Observable } from 'rxjs';
import { getRequestAbortedSignal } from '@kbn/data-plugin/server';
import { termsAggSuggestions } from '@kbn/unified-search-plugin/server/autocomplete/terms_agg';
import type { ConfigSchema } from '@kbn/unified-search-plugin/config';
import { UsageCounter } from '@kbn/usage-collection-plugin/server';
import { getKbnServerError, reportServerError } from '@kbn/kibana-utils-plugin/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
AlertConsumers,
ALERT_RULE_CONSUMER,
ALERT_RULE_TYPE_ID,
SPACE_IDS,
} from '@kbn/rule-data-utils';
import { verifyAccessAndContext } from '../lib';
import { RuleAuditAction, ruleAuditEvent } from '../../rules_client/common/audit_events';
import {
AlertingAuthorizationEntity,
AlertingAuthorizationFilterOpts,
AlertingAuthorizationFilterType,
} from '../../authorization';
import { AlertingRequestHandlerContext } from '../../types';
import { GetAlertIndicesAlias, ILicenseState } from '../../lib';
const alertingAuthorizationFilterOpts: AlertingAuthorizationFilterOpts = {
type: AlertingAuthorizationFilterType.ESDSL,
fieldNames: { ruleTypeId: ALERT_RULE_TYPE_ID, consumer: ALERT_RULE_CONSUMER },
};
export const AlertsSuggestionsSchema = {
body: schema.object({
field: schema.string(),
query: schema.string(),
filters: schema.maybe(schema.any()),
fieldMeta: schema.maybe(schema.any()),
}),
};
const VALID_FEATURE_IDS = new Set([
AlertConsumers.APM,
AlertConsumers.INFRASTRUCTURE,
AlertConsumers.LOGS,
AlertConsumers.SLO,
AlertConsumers.UPTIME,
]);
export function registerAlertsValueSuggestionsRoute(
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState,
config$: Observable<ConfigSchema>,
getAlertIndicesAlias?: GetAlertIndicesAlias,
usageCounter?: UsageCounter
) {
router.post(
{
path: '/internal/alerts/suggestions/values',
validate: AlertsSuggestionsSchema,
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, request, response) {
const config = await firstValueFrom(config$);
const { field: fieldName, query, fieldMeta } = request.body;
const abortSignal = getRequestAbortedSignal(request.events.aborted$);
const { savedObjects, elasticsearch } = await context.core;
const rulesClient = (await context.alerting).getRulesClient();
let authorizationTuple;
let authorizedRuleType = [];
try {
const authorization = rulesClient.getAuthorization();
authorizationTuple = await authorization.getFindAuthorizationFilter(
AlertingAuthorizationEntity.Alert,
alertingAuthorizationFilterOpts
);
authorizedRuleType = await authorization.getAuthorizedRuleTypes(
AlertingAuthorizationEntity.Alert,
VALID_FEATURE_IDS
);
} catch (error) {
rulesClient.getAuditLogger()?.log(
ruleAuditEvent({
action: RuleAuditAction.FIND,
error,
})
);
throw error;
}
const spaceId = rulesClient.getSpaceId();
const { filter: authorizationFilter } = authorizationTuple;
const filters = [
...(authorizationFilter != null ? [authorizationFilter] : []),
{ term: { [SPACE_IDS]: spaceId } },
] as estypes.QueryDslQueryContainer[];
const index = getAlertIndicesAlias!(
authorizedRuleType.map((art) => art.id),
spaceId
).join(',');
try {
const body = await termsAggSuggestions(
config,
savedObjects.client,
elasticsearch.client.asInternalUser,
index,
fieldName,
query,
filters,
fieldMeta,
abortSignal
);
return response.ok({ body });
} catch (e) {
const kbnErr = getKbnServerError(e);
return reportServerError(response, kbnErr);
}
})
)
);
}

View file

@ -13,7 +13,7 @@ import { Observable } from 'rxjs';
import { licenseStateMock } from '../../lib/license_state.mock';
import { rulesClientMock } from '../../rules_client.mock';
import { mockHandlerArguments } from '../_mock_handler_arguments';
import { registerValueSuggestionsRoute } from './values_suggestion_rules';
import { registerRulesValueSuggestionsRoute } from './values_suggestion_rules';
jest.mock('@kbn/unified-search-plugin/server/autocomplete/terms_agg', () => {
return {
@ -27,7 +27,7 @@ jest.mock('../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
describe('registerValueSuggestionsRoute', () => {
describe('registerRulesValueSuggestionsRoute', () => {
const rulesClient = rulesClientMock.create();
let config$: Observable<ConfigSchema>;
@ -43,7 +43,7 @@ describe('registerValueSuggestionsRoute', () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
registerValueSuggestionsRoute(router, licenseState, config$);
registerRulesValueSuggestionsRoute(router, licenseState, config$);
const [config, handler] = router.post.mock.calls[0];

View file

@ -40,7 +40,7 @@ export const RulesSuggestionsSchema = {
}),
};
export function registerValueSuggestionsRoute(
export function registerRulesValueSuggestionsRoute(
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState,
config$: Observable<ConfigSchema>,

View file

@ -33,6 +33,7 @@ const createRulesClientMock = () => {
getAuditLogger: jest.fn(),
getAuthorization: jest.fn().mockImplementation(() => ({
getFindAuthorizationFilter: jest.fn().mockReturnValue({ filter: null }),
getAuthorizedRuleTypes: jest.fn().mockResolvedValue([]),
})),
getExecutionLogForRule: jest.fn(),
getRuleExecutionKPI: jest.fn(),

View file

@ -119,7 +119,6 @@ export class RuleRegistryPlugin
core.getStartServices().then(([_, depsStart]) => {
const ruleRegistrySearchStrategy = ruleRegistrySearchStrategyProvider(
depsStart.data,
this.ruleDataService!,
depsStart.alerting,
logger,
plugins.security,

View file

@ -9,14 +9,12 @@ import { merge } from 'lodash';
import { loggerMock } from '@kbn/logging-mocks';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { ruleRegistrySearchStrategyProvider, EMPTY_RESPONSE } from './search_strategy';
import { ruleDataServiceMock } from '../rule_data_plugin_service/rule_data_plugin_service.mock';
import { dataPluginMock } from '@kbn/data-plugin/server/mocks';
import { SearchStrategyDependencies } from '@kbn/data-plugin/server';
import { alertsMock } from '@kbn/alerting-plugin/server/mocks';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { spacesMock } from '@kbn/spaces-plugin/server/mocks';
import { RuleRegistrySearchRequest } from '../../common/search_strategy';
import { IndexInfo } from '../rule_data_plugin_service/index_info';
import * as getAuthzFilterImport from '../lib/get_authz_filter';
import { getIsKibanaRequest } from '../lib/get_is_kibana_request';
@ -50,12 +48,12 @@ const getBasicResponse = (overwrites = {}) => {
describe('ruleRegistrySearchStrategyProvider()', () => {
const data = dataPluginMock.createStartContract();
const ruleDataService = ruleDataServiceMock.create();
const alerting = alertsMock.createStart();
const security = securityMock.createSetup();
const spaces = spacesMock.createStart();
const logger = loggerMock.create();
const getAuthorizedRuleTypesMock = jest.fn();
const getAlertIndicesAliasMock = jest.fn();
const response = getBasicResponse({
rawResponse: {
hits: {
@ -74,11 +72,13 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
const searchStrategySearch = jest.fn().mockImplementation(() => of(response));
beforeEach(() => {
ruleDataService.findIndexByFeature.mockImplementation(() => {
return {
baseName: 'test',
} as IndexInfo;
});
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['test']);
const authorizationMock = {
getAuthorizedRuleTypes: getAuthorizedRuleTypesMock,
} as never;
alerting.getAlertingAuthorizationWithRequest.mockResolvedValue(authorizationMock);
alerting.getAlertIndicesAlias = getAlertIndicesAliasMock;
data.search.getSearchStrategy.mockImplementation(() => {
return {
@ -102,7 +102,8 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
});
afterEach(() => {
ruleDataService.findIndexByFeature.mockClear();
getAuthorizedRuleTypesMock.mockClear();
getAlertIndicesAliasMock.mockClear();
data.search.getSearchStrategy.mockClear();
(data.search.searchAsInternalUser.search as jest.Mock).mockClear();
getAuthzFilterSpy.mockClear();
@ -110,6 +111,8 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
});
it('should handle a basic search request', async () => {
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['observability-logs']);
const request: RuleRegistrySearchRequest = {
featureIds: [AlertConsumers.LOGS],
};
@ -118,68 +121,13 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
request: {},
};
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
const result = await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
.toPromise();
expect(result).toBe(response);
});
it('should use the active space in siem queries', async () => {
const request: RuleRegistrySearchRequest = {
featureIds: [AlertConsumers.SIEM],
};
const options = {};
const deps = {
request: {},
};
spaces.spacesService.getActiveSpace.mockImplementation(async () => {
return {
id: 'testSpace',
name: 'Test Space',
disabledFeatures: [],
};
});
ruleDataService.findIndexByFeature.mockImplementation(() => {
return {
baseName: 'myTestIndex',
} as unknown as IndexInfo;
});
let searchRequest: RuleRegistrySearchRequest = {} as unknown as RuleRegistrySearchRequest;
data.search.getSearchStrategy.mockImplementation(() => {
return {
search: (_request) => {
searchRequest = _request as unknown as RuleRegistrySearchRequest;
return of(response);
},
};
});
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
.toPromise();
spaces.spacesService.getActiveSpace.mockClear();
expect(searchRequest?.params?.index).toStrictEqual(['myTestIndex-testSpace*']);
expect(result).toEqual(response);
});
it('should return an empty response if no valid indices are found', async () => {
@ -191,18 +139,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
request: {},
};
ruleDataService.findIndexByFeature.mockImplementationOnce(() => {
return null;
});
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue([]);
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
const result = await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
@ -219,14 +159,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
request: {},
};
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['security-siem']);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
@ -243,14 +179,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
request: {},
};
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['security-siem', 'o11y-logs']);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
let err;
try {
@ -271,15 +203,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
const deps = {
request: {},
};
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['o11y-logs']);
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
@ -297,14 +224,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
request: {},
};
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['security-siem']);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
@ -325,15 +248,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
const deps = {
request: {},
};
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['o11y-logs']);
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
@ -362,15 +280,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
const deps = {
request: {},
};
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['o11y-logs']);
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
@ -392,15 +305,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
const deps = {
request: {},
};
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['security-siem']);
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
@ -427,7 +335,7 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
sort: [],
},
ignore_unavailable: true,
index: ['test-testSpace*'],
index: ['security-siem'],
},
},
{},
@ -447,15 +355,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
const deps = {
request: {},
};
getAuthorizedRuleTypesMock.mockResolvedValue([]);
getAlertIndicesAliasMock.mockReturnValue(['security-siem']);
const strategy = ruleRegistrySearchStrategyProvider(
data,
ruleDataService,
alerting,
logger,
security,
spaces
);
const strategy = ruleRegistrySearchStrategyProvider(data, alerting, logger, security, spaces);
await strategy
.search(request, options, deps as unknown as SearchStrategyDependencies)
@ -477,7 +380,7 @@ describe('ruleRegistrySearchStrategyProvider()', () => {
sort: [],
},
ignore_unavailable: true,
index: ['test-testSpace*'],
index: ['security-siem'],
},
},
{},

View file

@ -12,15 +12,17 @@ import { isEmpty } from 'lodash';
import { isValidFeatureId, AlertConsumers } from '@kbn/rule-data-utils';
import { ENHANCED_ES_SEARCH_STRATEGY } from '@kbn/data-plugin/common';
import { ISearchStrategy, PluginStart } from '@kbn/data-plugin/server';
import { ReadOperations, PluginStartContract as AlertingStart } from '@kbn/alerting-plugin/server';
import {
ReadOperations,
PluginStartContract as AlertingStart,
AlertingAuthorizationEntity,
} from '@kbn/alerting-plugin/server';
import { SecurityPluginSetup } from '@kbn/security-plugin/server';
import { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import {
RuleRegistrySearchRequest,
RuleRegistrySearchResponse,
} from '../../common/search_strategy';
import { IRuleDataService } from '..';
import { Dataset } from '../rule_data_plugin_service/index_options';
import { MAX_ALERT_SEARCH_SIZE } from '../../common/constants';
import { AlertAuditAction, alertAuditEvent } from '..';
import { getSpacesFilter, getAuthzFilter } from '../lib';
@ -35,7 +37,6 @@ export const RULE_SEARCH_STRATEGY_NAME = 'privateRuleRegistryAlertsSearchStrateg
export const ruleRegistrySearchStrategyProvider = (
data: PluginStart,
ruleDataService: IRuleDataService,
alerting: AlertingStart,
logger: Logger,
security?: SecurityPluginSetup,
@ -57,10 +58,17 @@ export const ruleRegistrySearchStrategyProvider = (
`The ${RULE_SEARCH_STRATEGY_NAME} search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.`
);
}
request.featureIds.forEach((featureId) => {
if (!isValidFeatureId(featureId)) {
logger.warn(
`Found invalid feature '${featureId}' while using ${RULE_SEARCH_STRATEGY_NAME} search strategy. No alert data from this feature will be searched.`
);
}
});
const securityAuditLogger = security?.audit.asScoped(deps.request);
const getActiveSpace = async () => spaces?.spacesService.getActiveSpace(deps.request);
const getAsync = async () => {
const getAsync = async (featureIds: string[]) => {
const [space, authorization] = await Promise.all([
getActiveSpace(),
alerting.getAlertingAuthorizationWithRequest(deps.request),
@ -73,28 +81,22 @@ export const ruleRegistrySearchStrategyProvider = (
ReadOperations.Find
)) as estypes.QueryDslQueryContainer;
}
return { space, authzFilter };
};
return from(getAsync()).pipe(
mergeMap(({ space, authzFilter }) => {
const indices: string[] = request.featureIds.reduce((accum: string[], featureId) => {
if (!isValidFeatureId(featureId)) {
logger.warn(
`Found invalid feature '${featureId}' while using ${RULE_SEARCH_STRATEGY_NAME} search strategy. No alert data from this feature will be searched.`
);
return accum;
}
const alertIndexInfo = ruleDataService.findIndexByFeature(featureId, Dataset.alerts);
if (alertIndexInfo) {
accum.push(
featureId === 'siem'
? `${alertIndexInfo.baseName}-${space?.id ?? ''}*`
: `${alertIndexInfo.baseName}*`
);
}
return accum;
}, []);
const authorizedRuleTypes =
featureIds.length > 0
? await authorization.getAuthorizedRuleTypes(
AlertingAuthorizationEntity.Alert,
new Set(featureIds)
)
: [];
return { space, authzFilter, authorizedRuleTypes };
};
return from(getAsync(request.featureIds)).pipe(
mergeMap(({ space, authzFilter, authorizedRuleTypes }) => {
const indices = alerting.getAlertIndicesAlias(
authorizedRuleTypes.map((art: { id: any }) => art.id),
space?.id
);
if (indices.length === 0) {
return of(EMPTY_RESPONSE);
}

View file

@ -18,7 +18,7 @@ export const allowedExperimentalValues = Object.freeze({
ruleStatusFilter: true,
rulesDetailLogs: true,
ruleUseExecutionStatus: false,
ruleKqlBar: true,
ruleKqlBar: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -5,13 +5,11 @@
* 2.0.
*/
import { DataView } from '@kbn/data-views-plugin/common';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock';
import type { ValidFeatureId } from '@kbn/rule-data-utils';
import { act, renderHook } from '@testing-library/react-hooks';
import { AsyncState } from 'react-use/lib/useAsync';
import { useAlertDataView } from './use_alert_data_view';
import { useAlertDataView, UserAlertDataView } from './use_alert_data_view';
const mockUseKibanaReturnValue = createStartServicesMock();
@ -23,7 +21,6 @@ jest.mock('@kbn/kibana-react-plugin/public', () => ({
}));
describe('useAlertDataView', () => {
const mockedDataView = 'dataView';
const observabilityAlertFeatureIds: ValidFeatureId[] = [
AlertConsumers.APM,
AlertConsumers.INFRASTRUCTURE,
@ -40,7 +37,6 @@ describe('useAlertDataView', () => {
'.alerts-observability.apm.alerts-*',
],
});
mockUseKibanaReturnValue.data.dataViews.create = jest.fn().mockReturnValue(mockedDataView);
});
afterEach(() => {
@ -51,9 +47,10 @@ describe('useAlertDataView', () => {
await act(async () => {
const mockedAsyncDataView = {
loading: true,
error: undefined,
};
const { result, waitForNextUpdate } = renderHook<ValidFeatureId[], AsyncState<DataView>>(() =>
const { result, waitForNextUpdate } = renderHook<ValidFeatureId[], UserAlertDataView>(() =>
useAlertDataView(observabilityAlertFeatureIds)
);
@ -65,19 +62,26 @@ describe('useAlertDataView', () => {
it('returns dataView for the provided featureIds', async () => {
await act(async () => {
const mockedAsyncDataView = {
loading: false,
value: mockedDataView,
};
const { result, waitForNextUpdate } = renderHook<ValidFeatureId[], AsyncState<DataView>>(() =>
const { result, waitForNextUpdate } = renderHook<ValidFeatureId[], UserAlertDataView>(() =>
useAlertDataView(observabilityAlertFeatureIds)
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual(mockedAsyncDataView);
expect(result.current).toMatchInlineSnapshot(`
Object {
"error": undefined,
"loading": false,
"value": Array [
Object {
"fieldFormatMap": Object {},
"fields": Array [],
"title": ".alerts-observability.uptime.alerts-*,.alerts-observability.metrics.alerts-*,.alerts-observability.logs.alerts-*,.alerts-observability.apm.alerts-*",
},
],
}
`);
});
});
@ -88,19 +92,20 @@ describe('useAlertDataView', () => {
});
await act(async () => {
const mockedAsyncDataView = {
loading: false,
error,
};
const { result, waitForNextUpdate } = renderHook<ValidFeatureId[], AsyncState<DataView>>(() =>
const { result, waitForNextUpdate } = renderHook<ValidFeatureId[], UserAlertDataView>(() =>
useAlertDataView(observabilityAlertFeatureIds)
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual(mockedAsyncDataView);
expect(result.current).toMatchInlineSnapshot(`
Object {
"error": [Error: http error],
"loading": false,
"value": undefined,
}
`);
});
});
});

View file

@ -5,28 +5,72 @@
* 2.0.
*/
import { DataView } from '@kbn/data-views-plugin/common';
import { DataView, FieldSpec } from '@kbn/data-views-plugin/common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common';
import type { ValidFeatureId } from '@kbn/rule-data-utils';
import useAsync from 'react-use/lib/useAsync';
import type { AsyncState } from 'react-use/lib/useAsync';
import { useMemo } from 'react';
import { TriggersAndActionsUiServices } from '../..';
export function useAlertDataView(featureIds: ValidFeatureId[]): AsyncState<DataView> {
const { http, data: dataService } = useKibana<TriggersAndActionsUiServices>().services;
export interface UserAlertDataView {
value?: DataView[];
loading: boolean;
error?: Error;
}
export function useAlertDataView(featureIds: ValidFeatureId[]): UserAlertDataView {
const { http } = useKibana<TriggersAndActionsUiServices>().services;
const features = featureIds.sort().join(',');
const dataView = useAsync(async () => {
const { index_name: indexNames } = await http.get<{ index_name: string[] }>(
const indexNames = useAsync(async () => {
const { index_name: indexNamesStr } = await http.get<{ index_name: string[] }>(
`${BASE_RAC_ALERTS_API_PATH}/index`,
{
query: { features },
}
);
return dataService.dataViews.create({ title: indexNames.join(','), allowNoIndex: true });
return indexNamesStr;
}, [features]);
return dataView;
const fields = useAsync(async () => {
const { fields: alertFields } = await http.get<{ fields: FieldSpec[] }>(
`${BASE_RAC_ALERTS_API_PATH}/browser_fields`,
{
query: { featureIds },
}
);
return alertFields;
}, [features]);
const dataview = useMemo(
() =>
!fields.loading &&
!indexNames.loading &&
fields.error === undefined &&
indexNames.error === undefined
? ([
{
title: (indexNames.value ?? []).join(','),
fieldFormatMap: {},
fields: (fields.value ?? [])?.map((field) => {
return {
...field,
...(field.esTypes && field.esTypes.includes('flattened')
? { type: 'string' }
: {}),
};
}),
},
] as unknown as DataView[])
: undefined,
[fields, indexNames]
);
return {
value: dataview,
loading: fields.loading || indexNames.loading,
error: fields.error ? fields.error : indexNames.error,
};
}

View file

@ -8,6 +8,7 @@
import React, { useCallback, useState } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { Query, TimeRange } from '@kbn/es-query';
import { SuggestionsAbstraction } from '@kbn/unified-search-plugin/public/typeahead/suggestions_component';
import { NO_INDEX_PATTERNS } from './constants';
import { SEARCH_BAR_PLACEHOLDER } from './translations';
import { AlertsSearchBarProps, QueryLanguageType } from './types';
@ -15,6 +16,8 @@ import { useAlertDataView } from '../../hooks/use_alert_data_view';
import { TriggersAndActionsUiServices } from '../../..';
import { useRuleAADFields } from '../../hooks/use_rule_aad_fields';
const SA_ALERTS = { type: 'alerts', fields: {} } as SuggestionsAbstraction;
// TODO Share buildEsQuery to be used between AlertsSearchBar and AlertsStateTable component https://github.com/elastic/kibana/issues/144615
export function AlertsSearchBar({
appName,
@ -49,7 +52,7 @@ export function AlertsSearchBar({
} = useRuleAADFields(ruleTypeId);
const indexPatterns =
ruleTypeId && aadFields?.length ? [{ title: ruleTypeId, fields: aadFields }] : [dataView!];
ruleTypeId && aadFields?.length ? [{ title: ruleTypeId, fields: aadFields }] : dataView;
const onSearchQuerySubmit = useCallback(
({ dateRange, query: nextQuery }: { dateRange: TimeRange; query?: Query }) => {
@ -102,6 +105,7 @@ export function AlertsSearchBar({
showSubmitButton={showSubmitButton}
submitOnBlur={submitOnBlur}
onQueryChange={onSearchQueryChange}
suggestionsAbstraction={SA_ALERTS}
/>
);
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from 'expect';
import { Spaces } from '../../../scenarios';
import { getUrlPrefix } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function createRuleSuggestionValuesTests({ getService }: FtrProviderContext) {
const space1 = Spaces[0].id;
describe('alerts/suggestions/values', async () => {
const esArchiver = getService('esArchiver');
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts');
await esArchiver.load('x-pack/test/functional/es_archives/security_solution/alerts/8.1.0');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts');
await esArchiver.unload('x-pack/test/functional/es_archives/security_solution/alerts/8.1.0');
});
it('Get service.name value suggestion in default space for super user', async () => {
const response = await supertest
.post(`${getUrlPrefix('default')}/internal/alerts/suggestions/values`)
.set('kbn-xsrf', 'foo')
.send({
field: 'service.name',
filters: [],
query: 'op',
});
expect(response.body).toEqual(expect.arrayContaining(['opbeans-python', 'opbeans-java']));
});
it('Get service.name value suggestion in space 1 for super user', async () => {
const response = await supertest
.post(`${getUrlPrefix(space1)}/internal/alerts/suggestions/values`)
.set('kbn-xsrf', 'foo')
.send({
field: 'service.name',
filters: [],
query: 'op',
});
expect(response.body).toEqual([]);
});
});
}

View file

@ -191,6 +191,28 @@ export const securitySolutionOnlyAllSpacesAll: Role = {
},
};
export const securitySolutionOnlyAllSpacesAllWithReadESIndices: Role = {
name: 'sec_only_all_spaces_all_with_read_es_indices',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
siem: ['all'],
},
spaces: ['*'],
},
],
},
};
export const securitySolutionOnlyReadSpacesAll: Role = {
name: 'sec_only_read_spaces_all',
privileges: {
@ -468,6 +490,7 @@ export const allRoles = [
observabilityOnlyAll,
observabilityOnlyRead,
securitySolutionOnlyAllSpacesAll,
securitySolutionOnlyAllSpacesAllWithReadESIndices,
securitySolutionOnlyReadSpacesAll,
observabilityOnlyAllSpacesAll,
logsOnlyAllSpacesAll,

View file

@ -29,6 +29,7 @@ import {
observabilityOnlyReadSpace2,
observabilityMinReadAlertsAllSpacesAll,
observabilityOnlyAllSpacesAllWithReadESIndices,
securitySolutionOnlyAllSpacesAllWithReadESIndices,
} from './roles';
import { User } from './types';
@ -157,6 +158,12 @@ export const secOnlyReadSpacesAll: User = {
roles: [securitySolutionOnlyReadSpacesAll.name],
};
export const secOnlySpacesAllEsReadAll: User = {
username: 'sec_only_all_spaces_all_with_read_es_indices',
password: 'sec_only_all_spaces_all_with_read_es_indices',
roles: [securitySolutionOnlyAllSpacesAllWithReadESIndices.name],
};
export const obsOnlySpacesAll: User = {
username: 'obs_only_all_spaces_all',
password: 'obs_only_all_spaces_all',
@ -279,6 +286,7 @@ export const allUsers = [
noKibanaPrivileges,
obsOnlyReadSpacesAll,
secOnlySpacesAll,
secOnlySpacesAllEsReadAll,
secOnlyReadSpacesAll,
obsOnlySpacesAll,
logsOnlySpacesAll,

View file

@ -8,21 +8,11 @@ import expect from '@kbn/expect';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { RuleRegistrySearchResponse } from '@kbn/rule-registry-plugin/common/search_strategy';
import { QueryRuleCreateProps } from '@kbn/security-solution-plugin/common/api/detection_engine';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
deleteAllAlerts,
createSignalsIndex,
deleteAllRules,
getRuleForSignalTesting,
createRule,
waitForSignalsToBePresent,
waitForRuleSuccess,
} from '../../../../detection_engine_api_integration/utils';
import {
obsOnlySpacesAllEsRead,
obsOnlySpacesAll,
logsOnlySpacesAll,
secOnlySpacesAllEsReadAll,
} from '../../../common/lib/authentication/users';
type RuleRegistrySearchResponseWithErrors = RuleRegistrySearchResponse & {
@ -30,19 +20,12 @@ type RuleRegistrySearchResponseWithErrors = RuleRegistrySearchResponse & {
message: string;
};
const ID = 'BhbXBmkBR346wHgn4PeZ';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const secureBsearch = getService('secureBsearch');
const log = getService('log');
const kbnClient = getService('kibanaServer');
const es = getService('es');
const SPACE1 = 'space1';
describe('ruleRegistryAlertsSearchStrategy', () => {
let kibanaVersion: string;
@ -116,23 +99,14 @@ export default ({ getService }: FtrProviderContext) => {
describe('siem', () => {
before(async () => {
await createSignalsIndex(supertest, log);
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts');
const rule: QueryRuleCreateProps = {
...getRuleForSignalTesting(['auditbeat-*']),
query: `_id:${ID}`,
};
const { id: createdId } = await createRule(supertest, log, rule);
await waitForRuleSuccess({ supertest, log, id: createdId });
await waitForSignalsToBePresent(supertest, log, 1, [createdId]);
await esArchiver.load('x-pack/test/functional/es_archives/security_solution/alerts/8.1.0');
});
after(async () => {
await deleteAllAlerts(supertest, log, es);
await deleteAllRules(supertest, log);
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
await esArchiver.unload(
'x-pack/test/functional/es_archives/security_solution/alerts/8.1.0'
);
await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts');
});
@ -140,8 +114,8 @@ export default ({ getService }: FtrProviderContext) => {
const result = await secureBsearch.send<RuleRegistrySearchResponse>({
supertestWithoutAuth,
auth: {
username: obsOnlySpacesAllEsRead.username,
password: obsOnlySpacesAllEsRead.password,
username: secOnlySpacesAllEsReadAll.username,
password: secOnlySpacesAllEsReadAll.password,
},
referer: 'test',
kibanaVersion,
@ -151,7 +125,7 @@ export default ({ getService }: FtrProviderContext) => {
},
strategy: 'privateRuleRegistryAlertsSearchStrategy',
});
expect(result.rawResponse.hits.total).to.eql(1);
expect(result.rawResponse.hits.total).to.eql(50);
const consumers = result.rawResponse.hits.hits.map(
(hit) => hit.fields?.['kibana.alert.rule.consumer']
);
@ -162,8 +136,8 @@ export default ({ getService }: FtrProviderContext) => {
const result = await secureBsearch.send<RuleRegistrySearchResponseWithErrors>({
supertestWithoutAuth,
auth: {
username: obsOnlySpacesAllEsRead.username,
password: obsOnlySpacesAllEsRead.password,
username: secOnlySpacesAllEsReadAll.username,
password: secOnlySpacesAllEsReadAll.password,
},
referer: 'test',
kibanaVersion,
@ -185,8 +159,8 @@ export default ({ getService }: FtrProviderContext) => {
const result = await secureBsearch.send<RuleRegistrySearchResponse>({
supertestWithoutAuth,
auth: {
username: obsOnlySpacesAllEsRead.username,
password: obsOnlySpacesAllEsRead.password,
username: secOnlySpacesAllEsReadAll.username,
password: secOnlySpacesAllEsReadAll.password,
},
referer: 'test',
kibanaVersion,
@ -204,7 +178,7 @@ export default ({ getService }: FtrProviderContext) => {
},
strategy: 'privateRuleRegistryAlertsSearchStrategy',
});
expect(result.rawResponse.hits.total).to.eql(1);
expect(result.rawResponse.hits.total).to.eql(50);
const runtimeFields = result.rawResponse.hits.hits.map(
(hit) => hit.fields?.[runtimeFieldKey]
);
@ -214,10 +188,10 @@ export default ({ getService }: FtrProviderContext) => {
describe('apm', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts');
await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts');
});
it('should return alerts from apm rules', async () => {
@ -234,9 +208,9 @@ export default ({ getService }: FtrProviderContext) => {
featureIds: [AlertConsumers.APM],
},
strategy: 'privateRuleRegistryAlertsSearchStrategy',
space: SPACE1,
space: 'default',
});
expect(result.rawResponse.hits.total).to.eql(2);
expect(result.rawResponse.hits.total).to.eql(9);
const consumers = result.rawResponse.hits.hits.map(
(hit) => hit.fields?.['kibana.alert.rule.consumer']
);