[Observability] [Serverless] Introduce custom roles (#219861)

## Summary

Closes https://github.com/elastic/observability-dev/issues/4539
Fixes https://github.com/elastic/kibana/issues/221035

Enables custom roles for Observability projects in serverless.

The following is a summary of the changes:

## Feature renaming

1. Renamed `Uptime and Synthetics` to `Synthetics`
2. Renamed `APM and User Experience` to `Applications`
3. Renamed `Metrics` to `Infrastructure`

## Category reassignment

1. Changed `Dashboard` category from `Analytics` to `Observability` 
2. Changed `Discover` category from `Analytics` to `Observability`
3. Changed `ML` category from `Analytics` to `Observability`

## Feature hiding

1. Hides the `Stack Alerts` feature.
2. Provides backwards compatibility for alerts created via Stack Alerts.
This enables our users to import rules created within Stack Alerts and
expect to see them in the Observability rules table.

## Navigation updates

1. Adds a `Custom Roles` link under the `Access` section in the
management navigation
2. Adds a `Manage Organization Members` link under the `Access` section
in the management navigation
3. Removes the `Users and Roles` link from the navigation footer (in
favor of the `Manage Organization Members link)

## Bug fixes

1. Fixes a bug where the `Alerts` link was not shown for Synthetics only
user (in stateful and serverless)
2. Fixes a bug where the `Alerts` link was not shown for Logs only user
(in stateful and serverless)

## Alert Override Removal

In the alerting framework, each rule is assigned a `consumer` value.
This `consumer` value changes depending on where the rule is created in
Kibana. However, in serverless we introduced an override that caused the
`consumer` value to be `Observability` in nearly every case. This logic
branched from stateful causing complexity and a large mental burden for
our engineers. Ultimately, this override became the source of bugs,
uncertainty, and unintended user experiences. Because of this, we've
removed this overrides.

If we kept this override, it would have the unfortunate side effect of
making all rules created in serverless visible from all custom roles (an
APM only user would have been can see Synthetics rules, and vice
versus). To make things more unpredictable, when users import their
rules from stateful the behavior would be different (access would be
properly mapped to the specific feature).

To address these specific user experience issues, and remove the source
of complexity, branching logic, and bugs, we removed this override logic
and restored the rule access behavior to match with stateful.

We did this while introducing backwards compatibility logic, ensuring
rules created in earlier versions of an oblt stateful cluster continue
to work and are accessible by a user with the right role access.

# Testing

1. Run local ES
```
yarn es serverless --projectType=oblt -E xpack.security.authc.native_roles.enabled=true    
```

2. Run local Kibana
```
yarn start --serverless=oblt --xpack.security.roleManagementEnabled=true --xpack.cloud.users_and_roles_url="https://test_users_and_roles_url"
```
3. Login to Kibana with the admin role. Navigate to the Custom Roles
page via the management navigation.
4. Create a custom role 
5. Log out of Kibana
6. Log back in with your custom role. You can do so by typing the custom
role name into the mock saml auth
<img width="460" alt="Screenshot 2025-05-22 at 9 23 13 PM"
src="https://github.com/user-attachments/assets/8e7f659b-5fe9-4e74-8c57-b420467d309e"
/>

---------

Co-authored-by: Jason Rhodes <jason.rhodes@elastic.co>
Co-authored-by: Faisal Kanout <faisal.kanout@elastic.co>
Co-authored-by: “jeramysoucy” <jeramy.soucy@elastic.co>
This commit is contained in:
Dominique Clarke 2025-06-13 22:03:49 -04:00 committed by GitHub
parent af7ed3f2a3
commit f15d325e3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 5524 additions and 21681 deletions

View file

@ -44,4 +44,4 @@ enabled:
- x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.apm.serverless.config.ts
- x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.synthetics.serverless.config.ts
- x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.streams.serverless.config.ts
- x-pack/test/api_integration/deployment_agnostic/feature_flag_configs/serverless/oblt.synthetics.serverless.config.ts
- x-pack/test/api_integration/deployment_agnostic/feature_flag_configs/serverless/oblt.serverless.config.ts

View file

@ -48,4 +48,4 @@ enabled:
- x-pack/test/api_integration/deployment_agnostic/configs/stateful/oblt.stateful.config.ts
- x-pack/test/api_integration/deployment_agnostic/configs/stateful/oblt.apm.stateful.config.ts
- x-pack/test/api_integration/deployment_agnostic/configs/stateful/oblt.ai_assistant.stateful.config.ts
- x-pack/test/api_integration/deployment_agnostic/feature_flag_configs/stateful/oblt.synthetics.stateful.config.ts
- x-pack/test/api_integration/deployment_agnostic/feature_flag_configs/stateful/oblt.stateful.config.ts

View file

@ -5,59 +5,5 @@ xpack.infra.enabled: true
xpack.slo.enabled: true
xpack.features.overrides:
### Applications feature privileges are fine-tuned to grant access to Logs, and Observability apps.
apm:
### By default, this feature named as `APM and User Experience`, but should be renamed to `Applications`.
name: "Applications"
privileges:
# Infrastructure's `All` feature privilege should implicitly grant `All` access to Logs and Observability apps.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
- feature: "observability"
privileges: [ "all" ]
# Infrastructure's `Read` feature privilege should implicitly grant `Read` access to Logs and Observability apps.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
- feature: "observability"
privileges: [ "read" ]
### Fleet feature privileges are fine-tuned to grant access to Logs app.
fleetv2:
privileges:
# Fleet `All` feature privilege should implicitly grant `All` access to Logs app.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
# Fleet `Read` feature privilege should implicitly grant `Read` access to Logs app.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
infrastructure:
### By default, this feature named as `Metrics`, but should be renamed to `Infrastructure`.
name: "Infrastructure"
privileges:
# Infrastructure's `All` feature privilege should implicitly grant `All` access to Logs and Observability apps.
all.composedOf:
- feature: "logs"
privileges: [ "all" ]
- feature: "observability"
privileges: [ "all" ]
# Infrastructure's `Read` feature privilege should implicitly grant `Read` access to Logs and Observability apps.
read.composedOf:
- feature: "logs"
privileges: [ "read" ]
- feature: "observability"
privileges: [ "read" ]
### Logs feature is hidden in Role management since it's automatically granted by either Infrastructure, or Applications features.
logs.hidden: true
slo:
privileges:
# SLOs `All` feature privilege should implicitly grant `All` access to Observability app.
all.composedOf:
- feature: "observability"
privileges: [ "all" ]
# SLOs `Read` feature privilege should implicitly grant `Read` access to Observability app.
read.composedOf:
- feature: "observability"
privileges: [ "read" ]
### By default, this feature named as `Metrics`, but should be renamed to `Infrastructure`.
infrastructure.name: 'Infrastructure'

View file

@ -14,54 +14,21 @@ xpack.ux.enabled: false
xpack.legacy_uptime.enabled: false
xpack.features.overrides:
### By default, this feature named as `APM and User Experience`, but should be renamed to `Applications`.
apm.name: 'Applications'
### Dashboards feature should be moved from Analytics category to the Observability one.
dashboard_v2.category: "observability"
dashboard_v2.category: 'observability'
### Discover feature should be moved from Analytics category to the Observability one and its privileges are
### fine-tuned to grant access to Observability app.
discover:
privileges:
# Discover `All` feature privilege should implicitly grant `All` access to Observability app.
all.composedOf:
- feature: "observability"
privileges: [ "all" ]
# Discover `Read` feature privilege should implicitly grant `Read` access to Observability app.
read.composedOf:
- feature: "observability"
privileges: [ "read" ]
discover_v2:
category: "observability"
privileges:
# Discover `All` feature privilege should implicitly grant `All` access to Observability app.
all.composedOf:
- feature: "observability"
privileges: [ "all" ]
# Discover `Read` feature privilege should implicitly grant `Read` access to Observability app.
read.composedOf:
- feature: "observability"
privileges: [ "read" ]
discover_v2.category: 'observability'
### Machine Learning feature should be moved from Analytics category to the Observability one and renamed to `AI Ops`.
ml:
category: "observability"
category: 'observability'
order: 1200
### Observability feature is hidden in Role management since it's automatically granted by either Discover,
### Infrastructure, Applications, Synthetics, or SLOs features.
observability.hidden: true
### Stack alerts is hidden in Role management since it's not needed.
stackAlerts.hidden: true
### Synthetics feature privileges are fine-tuned to grant access to Observability app.
uptime:
### By default, this feature named as `Synthetics and Uptime`, but should be renamed to `Synthetics` since `Uptime` is not available.
name: "Synthetics"
privileges:
# Synthetics `All` feature privilege should implicitly grant `All` access to Observability app.
all.composedOf:
- feature: "observability"
privileges: [ "all" ]
# Synthetics `Read` feature privilege should implicitly grant `Read` access to Observability app.
read.composedOf:
- feature: "observability"
privileges: [ "read" ]
### By default, this feature named as `Synthetics and Uptime`, but should be renamed to `Synthetics` since `Uptime` is not available.
uptime.name: 'Synthetics'
## Cloud settings
xpack.cloud.serverless.project_type: observability
@ -81,11 +48,6 @@ xpack.fleet.agentIdVerificationEnabled: false
## Enable event.ingested separately because agentIdVerification is disabled
xpack.fleet.eventIngestedEnabled: true
## Enable the capability for the observability feature ID in the serverless environment to take ownership of the rules.
## The value need to be a featureId observability Or stackAlerts Or siem
xpack.alerting.rules.overwriteProducer: 'observability'
xpack.observability.createO11yGenericFeatureId: true
## APM Serverless Onboarding flow
xpack.apm.serverlessOnboarding: true
@ -140,8 +102,8 @@ xpack.apm.featureFlags.sourcemapApiAvailable: false
xpack.apm.featureFlags.storageExplorerAvailable: false
## Set the AI Assistant type
aiAssistantManagementSelection.preferredAIAssistantType: "observability"
xpack.observabilityAIAssistant.scope: "observability"
aiAssistantManagementSelection.preferredAIAssistantType: 'observability'
xpack.observabilityAIAssistant.scope: 'observability'
# Specify in telemetry the project type
telemetry.labels.serverless: observability

View file

@ -33,6 +33,7 @@ export const AlertConsumers = {
DISCOVER: 'discover',
} as const;
export type AlertConsumers = (typeof AlertConsumers)[keyof typeof AlertConsumers];
export const DEPRECATED_ALERTING_CONSUMERS = [AlertConsumers.OBSERVABILITY];
export type STATUS_VALUES = 'open' | 'acknowledged' | 'closed' | 'in-progress'; // TODO: remove 'in-progress' after migration to 'acknowledged'
export type ValidFeatureId = AlertConsumers;

View file

@ -46,7 +46,6 @@ export interface CreateRuleFormProps {
canShowConsumerSelection?: boolean;
showMustacheAutocompleteSwitch?: boolean;
isFlyout?: boolean;
isServerless?: boolean;
onCancel?: () => void;
onSubmit?: (ruleId: string) => void;
onChangeMetaData?: (metadata?: RuleTypeMetaData) => void;
@ -67,7 +66,6 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => {
canShowConsumerSelection = true,
showMustacheAutocompleteSwitch = false,
isFlyout,
isServerless = false,
onCancel,
onSubmit,
onChangeMetaData,
@ -213,7 +211,6 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => {
validConsumers,
ruleType,
ruleTypes,
isServerless,
}),
}}
>

View file

@ -41,7 +41,6 @@ export interface RuleFormProps<MetaData extends RuleTypeMetaData = RuleTypeMetaD
showMustacheAutocompleteSwitch?: boolean;
initialValues?: Partial<Omit<RuleFormData, 'ruleTypeId'>>;
initialMetadata?: MetaData;
isServerless?: boolean;
}
export const RuleForm = <MetaData extends RuleTypeMetaData = RuleTypeMetaData>(
@ -66,7 +65,6 @@ export const RuleForm = <MetaData extends RuleTypeMetaData = RuleTypeMetaData>(
showMustacheAutocompleteSwitch,
initialValues,
initialMetadata,
isServerless,
} = props;
const {
@ -147,7 +145,6 @@ export const RuleForm = <MetaData extends RuleTypeMetaData = RuleTypeMetaData>(
showMustacheAutocompleteSwitch={showMustacheAutocompleteSwitch}
initialValues={initialValues}
initialMetadata={initialMetadata}
isServerless={isServerless}
/>
);
}
@ -179,7 +176,6 @@ export const RuleForm = <MetaData extends RuleTypeMetaData = RuleTypeMetaData>(
ruleTypeRegistry,
actionTypeRegistry,
contentManagement,
isServerless,
id,
ruleTypeId,
validConsumers,

View file

@ -0,0 +1,82 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { AlertConsumers, DEPRECATED_ALERTING_CONSUMERS } from '@kbn/rule-data-utils';
import { getAuthorizedConsumers } from './get_authorized_consumers';
import type { RuleTypeWithDescription } from '../common/types';
import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
describe('getAuthorizedConsumers', () => {
// @ts-ignore
const mockValidConsumers = ['consumer1', 'consumer2'] as RuleCreationValidConsumer[];
it('should return authorized consumers that have "all" privileges and are valid', () => {
const mockRuleType: RuleTypeWithDescription = {
authorizedConsumers: {
consumer1: { all: true },
consumer2: { all: false },
consumer3: { all: true },
},
} as unknown as RuleTypeWithDescription;
const result = getAuthorizedConsumers({
ruleType: mockRuleType,
validConsumers: mockValidConsumers,
});
expect(result).toEqual(['consumer1']);
});
it('should filter out deprecated consumers', () => {
const mockRuleType: RuleTypeWithDescription = {
authorizedConsumers: {
[AlertConsumers.OBSERVABILITY]: { all: true },
consumer1: { all: true },
},
} as unknown as RuleTypeWithDescription;
const result = getAuthorizedConsumers({
ruleType: mockRuleType,
validConsumers: [...mockValidConsumers, ...DEPRECATED_ALERTING_CONSUMERS],
});
expect(result).toEqual(['consumer1']);
});
it('should return an empty array if no consumers have "all" privileges', () => {
const mockRuleType: RuleTypeWithDescription = {
authorizedConsumers: {
consumer1: { all: false },
consumer2: { all: false },
},
} as unknown as RuleTypeWithDescription;
const result = getAuthorizedConsumers({
ruleType: mockRuleType,
validConsumers: mockValidConsumers,
});
expect(result).toEqual([]);
});
it('should return an empty array if no valid consumers are authorized', () => {
const mockRuleType: RuleTypeWithDescription = {
authorizedConsumers: {
consumer3: { all: true },
},
} as unknown as RuleTypeWithDescription;
const result = getAuthorizedConsumers({
ruleType: mockRuleType,
validConsumers: mockValidConsumers,
});
expect(result).toEqual([]);
});
});

View file

@ -8,6 +8,7 @@
*/
import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { DEPRECATED_ALERTING_CONSUMERS } from '@kbn/rule-data-utils';
import type { RuleTypeWithDescription } from '../common/types';
export const getAuthorizedConsumers = ({
@ -17,8 +18,8 @@ export const getAuthorizedConsumers = ({
ruleType: RuleTypeWithDescription;
validConsumers: RuleCreationValidConsumer[];
}) => {
return Object.entries(ruleType.authorizedConsumers).reduce<RuleCreationValidConsumer[]>(
(result, [authorizedConsumer, privilege]) => {
return Object.entries(ruleType.authorizedConsumers)
.reduce<RuleCreationValidConsumer[]>((result, [authorizedConsumer, privilege]) => {
if (
privilege.all &&
validConsumers.includes(authorizedConsumer as RuleCreationValidConsumer)
@ -26,7 +27,11 @@ export const getAuthorizedConsumers = ({
result.push(authorizedConsumer as RuleCreationValidConsumer);
}
return result;
},
[]
);
}, [])
.filter((consumer) => {
// Filter out deprecated alerting consumers
return !(DEPRECATED_ALERTING_CONSUMERS as RuleCreationValidConsumer[]).includes(
consumer as RuleCreationValidConsumer
);
});
};

View file

@ -47,37 +47,7 @@ describe('getInitialMultiConsumer', () => {
} as RuleTypeWithDescription;
const ruleTypes = [
{
id: '.es-query',
name: 'Test',
actionGroups: [
{
id: 'testActionGroup',
name: 'Test Action Group',
},
{
id: 'recovered',
name: 'Recovered',
},
],
defaultActionGroupId: 'testActionGroup',
minimumLicenseRequired: 'basic',
recoveryActionGroup: {
id: 'recovered',
},
producer: 'logs',
authorizedConsumers: {
alerting: { read: true, all: true },
test: { read: true, all: true },
stackAlerts: { read: true, all: true },
logs: { read: true, all: true },
},
actionVariables: {
params: [],
state: [],
},
enabledInLicense: true,
},
ruleType,
{
enabledInLicense: true,
recoveryActionGroup: {
@ -108,13 +78,12 @@ describe('getInitialMultiConsumer', () => {
test('should return null when rule type id does not match', () => {
const res = getInitialMultiConsumer({
multiConsumerSelection: null,
validConsumers: ['logs', 'observability'],
validConsumers: ['logs'],
ruleType: {
...ruleType,
id: 'test',
},
ruleTypes,
isServerless: false,
});
expect(res).toBe(null);
@ -126,7 +95,6 @@ describe('getInitialMultiConsumer', () => {
validConsumers: [],
ruleType,
ruleTypes,
isServerless: false,
});
expect(res).toBe(null);
@ -138,7 +106,6 @@ describe('getInitialMultiConsumer', () => {
validConsumers: ['alerts'],
ruleType,
ruleTypes,
isServerless: false,
});
expect(res).toBe('alerts');
@ -147,25 +114,49 @@ describe('getInitialMultiConsumer', () => {
test('should not return observability consumer for non serverless', () => {
const res = getInitialMultiConsumer({
multiConsumerSelection: null,
validConsumers: ['logs', 'infrastructure', 'observability'],
validConsumers: ['logs', 'infrastructure'],
ruleType,
ruleTypes,
isServerless: false,
});
expect(res).toBe('logs');
});
test('should return observability consumer for serverless', () => {
test('should return valid consumer when user has only logs privileges', () => {
const res = getInitialMultiConsumer({
multiConsumerSelection: null,
validConsumers: ['logs', 'infrastructure', 'observability'],
ruleType,
validConsumers: ['infrastructure', 'logs'],
ruleType: {
...ruleType,
authorizedConsumers: {
logs: { read: true, all: true },
},
},
ruleTypes,
isServerless: true,
});
expect(res).toBe('observability');
expect(res).toBe('logs');
});
test('should return valid consumer when user has only infrastructure privileges', () => {
const res = getInitialMultiConsumer({
multiConsumerSelection: null,
validConsumers: ['infrastructure', 'logs'],
ruleType: {
...ruleType,
authorizedConsumers: {
infrastructure: { read: true, all: true },
},
},
ruleTypes: ruleTypes.map((rule) => ({
...rule,
authorizedConsumers: {
infrastructure: { read: true, all: true },
},
})),
});
expect(res).toBe('infrastructure');
});
test('should return null when there is no authorized consumers', () => {
@ -177,7 +168,6 @@ describe('getInitialMultiConsumer', () => {
authorizedConsumers: {},
},
ruleTypes,
isServerless: false,
});
expect(res).toBe(null);
@ -194,7 +184,6 @@ describe('getInitialMultiConsumer', () => {
},
},
ruleTypes,
isServerless: false,
});
expect(res).toBe(null);
@ -211,7 +200,6 @@ describe('getInitialMultiConsumer', () => {
},
},
ruleTypes,
isServerless: false,
});
expect(res).toBe('logs');
@ -226,7 +214,6 @@ describe('getInitialMultiConsumer', () => {
authorizedConsumers: {},
},
ruleTypes,
isServerless: false,
});
expect(res).toBe('stackAlerts');
@ -241,7 +228,6 @@ describe('getInitialMultiConsumer', () => {
authorizedConsumers: {},
},
ruleTypes: [],
isServerless: false,
});
expect(res).toBe(null);

View file

@ -36,13 +36,11 @@ export const getInitialMultiConsumer = ({
validConsumers,
ruleType,
ruleTypes,
isServerless,
}: {
multiConsumerSelection?: RuleCreationValidConsumer | null;
validConsumers: RuleCreationValidConsumer[];
ruleType: RuleTypeWithDescription;
ruleTypes: RuleTypeWithDescription[];
isServerless?: boolean;
}): RuleCreationValidConsumer | null => {
// If rule type doesn't support multi-consumer or no valid consumers exists,
// return nothing
@ -55,11 +53,6 @@ export const getInitialMultiConsumer = ({
return validConsumers[0];
}
// If o11y is in the valid consumers and it is serverless, just use that
if (isServerless && validConsumers.includes(AlertConsumers.OBSERVABILITY)) {
return AlertConsumers.OBSERVABILITY;
}
const selectedAvailableRuleType = ruleTypes.find((availableRuleType) => {
return availableRuleType.id === ruleType.id;
});
@ -87,7 +80,7 @@ export const getInitialMultiConsumer = ({
validConsumers,
});
// If validated consumer exists and no o11y in valid consumers, just use that
// If validated consumer exists just use that
if (validatedConsumer) {
return validatedConsumer;
}

View file

@ -32005,7 +32005,6 @@
"xpack.observability.metric.iconSelect.tagIconLabel": "Balise",
"xpack.observability.metric.iconSelect.temperatureLabel": "Température",
"xpack.observability.metricWithSparkline.nATextColorLabel": "S. O.",
"xpack.observability.nameFeatureTitle": "Observabilité",
"xpack.observability.news.readFullStory": "Lire toute l'histoire",
"xpack.observability.news.title": "Nouveautés",
"xpack.observability.noDataConfig.beatsCard.description": "Utilisez des agents Beats et APM pour envoyer des données d'observabilité à Elasticsearch. Nous facilitons les choses en proposant un support technique pour un grand nombre de langues, d'applications et de systèmes courants.",

View file

@ -31983,7 +31983,6 @@
"xpack.observability.metric.iconSelect.tagIconLabel": "タグ",
"xpack.observability.metric.iconSelect.temperatureLabel": "温度",
"xpack.observability.metricWithSparkline.nATextColorLabel": "N/A",
"xpack.observability.nameFeatureTitle": "Observability",
"xpack.observability.news.readFullStory": "詳細なストーリーを読む",
"xpack.observability.news.title": "新機能",
"xpack.observability.noDataConfig.beatsCard.description": "BeatsとAPMエージェントを使用して、オブザーバビリティデータをElasticsearchに送信します。多数の一般的なシステム、アプリ、言語では、サポートによって処理が簡単になりました。",

View file

@ -32038,7 +32038,6 @@
"xpack.observability.metric.iconSelect.tagIconLabel": "标签",
"xpack.observability.metric.iconSelect.temperatureLabel": "温度",
"xpack.observability.metricWithSparkline.nATextColorLabel": "不可用",
"xpack.observability.nameFeatureTitle": "Observability",
"xpack.observability.news.readFullStory": "详细了解",
"xpack.observability.news.title": "最新动态",
"xpack.observability.noDataConfig.beatsCard.description": "使用 Beats 和 APM 代理将 Observability 数据发送到 Elasticsearch。我们使许多流行系统、应用和语言都可以轻松获取支持。",

View file

@ -6,6 +6,7 @@
*/
import type { KibanaRequest } from '@kbn/core/server';
import { DEPRECATED_ALERTING_CONSUMERS } from '@kbn/rule-data-utils';
import { ALERTING_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { PLUGIN_ID } from '../constants/app';
import {
@ -110,9 +111,9 @@ export function getDefaultCapabilities(): MlCapabilities {
};
}
const alertingFeatures = Object.values(ML_ALERT_TYPES).map((ruleTypeId) => ({
export const alertingFeatures = Object.values(ML_ALERT_TYPES).map((ruleTypeId) => ({
ruleTypeId,
consumers: [PLUGIN_ID, ALERTING_FEATURE_ID],
consumers: [PLUGIN_ID, ALERTING_FEATURE_ID, ...DEPRECATED_ALERTING_CONSUMERS],
}));
export function getPluginPrivileges() {

View file

@ -26,10 +26,9 @@ import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/server';
import type { HomeServerPluginSetup } from '@kbn/home-plugin/server';
import type { CasesServerSetup } from '@kbn/cases-plugin/server';
import { ALERTING_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import type { PluginsSetup, PluginsStart, RouteInitialization } from './types';
import type { MlCapabilities } from '../common/types/capabilities';
import { type MlCapabilities, alertingFeatures } from '../common/types/capabilities';
import { notificationsRoutes } from './routes/notifications';
import {
type MlFeatures,
@ -70,7 +69,6 @@ import {
} from './saved_objects';
import { RouteGuard } from './lib/route_guard';
import { registerMlAlerts } from './lib/alerts/register_ml_alerts';
import { ML_ALERT_TYPES } from '../common/constants/alerts';
import { alertingRoutes } from './routes/alerting';
import { registerCollector } from './usage';
import { SavedObjectsSyncService } from './saved_objects/sync_task';
@ -143,10 +141,7 @@ export class MlServerPlugin
management: {
insightsAndAlerting: ['jobsListLink', 'triggersActions'],
},
alerting: Object.values(ML_ALERT_TYPES).map((ruleTypeId) => ({
ruleTypeId,
consumers: [PLUGIN_ID, ALERTING_FEATURE_ID],
})),
alerting: alertingFeatures,
privileges: {
all: admin,
read: user,

View file

@ -29,7 +29,6 @@ export const RuleFormRoute = () => {
actionTypeRegistry,
contentManagement,
chrome,
isServerless,
setBreadcrumbs,
...startServices
} = useKibana().services;
@ -78,7 +77,6 @@ export const RuleFormRoute = () => {
contentManagement,
...startServices,
}}
isServerless={isServerless}
id={id}
ruleTypeId={ruleTypeId}
onCancel={() => {

View file

@ -14,7 +14,7 @@ import type {
} from '@kbn/licensing-plugin/server';
import { APM_INDEX_SETTINGS_SAVED_OBJECT_TYPE } from '@kbn/apm-sources-access-plugin/server/saved_objects/apm_indices';
import { ApmRuleType } from '@kbn/rule-data-utils';
import { ApmRuleType, DEPRECATED_ALERTING_CONSUMERS } from '@kbn/rule-data-utils';
import { ALERTING_FEATURE_ID } from '@kbn/alerting-plugin/common';
import type { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
@ -22,7 +22,7 @@ import { APM_SERVER_FEATURE_ID } from '../common/rules/apm_rule_types';
const alertingFeatures = Object.values(ApmRuleType).map((ruleTypeId) => ({
ruleTypeId,
consumers: [APM_SERVER_FEATURE_ID, ALERTING_FEATURE_ID],
consumers: [APM_SERVER_FEATURE_ID, ALERTING_FEATURE_ID, ...DEPRECATED_ALERTING_CONSUMERS],
}));
export const APM_FEATURE: KibanaFeatureConfig = {

View file

@ -9,12 +9,14 @@ import { i18n } from '@kbn/i18n';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { logViewSavedObjectName } from '@kbn/logs-shared-plugin/server';
import {
DEPRECATED_ALERTING_CONSUMERS,
ML_ANOMALY_DETECTION_RULE_TYPE_ID,
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
} from '@kbn/rule-data-utils';
import { ES_QUERY_ID } from '@kbn/rule-data-utils';
import { metricsDataSourceSavedObjectName } from '@kbn/metrics-data-access-plugin/server';
import { ALERTING_FEATURE_ID } from '@kbn/alerting-plugin/common';
import type { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import { LOG_DOCUMENT_COUNT_RULE_TYPE_ID } from '../common/alerting/logs/log_threshold/types';
import {
@ -32,69 +34,76 @@ const metricRuleTypes = [
ML_ANOMALY_DETECTION_RULE_TYPE_ID,
];
const metricAlertingFeatures = metricRuleTypes.map((ruleTypeId) => ({
ruleTypeId,
consumers: [METRICS_FEATURE_ID, ALERTING_FEATURE_ID],
}));
export const getMetricsFeature = (): KibanaFeatureConfig => {
const metricAlertingFeatures = metricRuleTypes.map((ruleTypeId) => {
const consumers = [METRICS_FEATURE_ID, ALERTING_FEATURE_ID, ...DEPRECATED_ALERTING_CONSUMERS];
export const METRICS_FEATURE = {
id: METRICS_FEATURE_ID,
name: i18n.translate('xpack.infra.featureRegistry.linkInfrastructureTitle', {
defaultMessage: 'Infrastructure',
}),
order: 800,
category: DEFAULT_APP_CATEGORIES.observability,
scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security],
app: ['infra', 'metrics', 'kibana'],
catalogue: ['infraops', 'metrics'],
management: {
insightsAndAlerting: ['triggersActions'],
},
alerting: metricAlertingFeatures,
privileges: {
all: {
app: ['infra', 'metrics', 'kibana'],
catalogue: ['infraops', 'metrics'],
api: ['infra', 'rac'],
savedObject: {
all: ['infrastructure-ui-source', metricsDataSourceSavedObjectName],
read: ['index-pattern'],
},
alerting: {
rule: {
all: metricAlertingFeatures,
},
alert: {
all: metricAlertingFeatures,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'configureSource', 'save'],
return {
ruleTypeId,
consumers,
};
});
const METRICS_FEATURE = {
id: METRICS_FEATURE_ID,
name: i18n.translate('xpack.infra.featureRegistry.linkInfrastructureTitle', {
defaultMessage: 'Infrastructure',
}),
order: 800,
category: DEFAULT_APP_CATEGORIES.observability,
scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security],
app: ['infra', 'metrics', 'kibana'],
catalogue: ['infraops', 'metrics'],
management: {
insightsAndAlerting: ['triggersActions'],
},
read: {
app: ['infra', 'metrics', 'kibana'],
catalogue: ['infraops', 'metrics'],
api: ['infra', 'rac'],
savedObject: {
all: [],
read: ['infrastructure-ui-source', 'index-pattern', metricsDataSourceSavedObjectName],
},
alerting: {
rule: {
read: metricAlertingFeatures,
alerting: metricAlertingFeatures,
privileges: {
all: {
app: ['infra', 'metrics', 'kibana'],
catalogue: ['infraops', 'metrics'],
api: ['infra', 'rac'],
savedObject: {
all: ['infrastructure-ui-source', metricsDataSourceSavedObjectName],
read: ['index-pattern'],
},
alert: {
read: metricAlertingFeatures,
alerting: {
rule: {
all: metricAlertingFeatures,
},
alert: {
all: metricAlertingFeatures,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'configureSource', 'save'],
},
management: {
insightsAndAlerting: ['triggersActions'],
read: {
app: ['infra', 'metrics', 'kibana'],
catalogue: ['infraops', 'metrics'],
api: ['infra', 'rac'],
savedObject: {
all: [],
read: ['infrastructure-ui-source', 'index-pattern', metricsDataSourceSavedObjectName],
},
alerting: {
rule: {
read: metricAlertingFeatures,
},
alert: {
read: metricAlertingFeatures,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show'],
},
ui: ['show'],
},
},
};
return METRICS_FEATURE;
};
const logsRuleTypes = [
@ -103,68 +112,74 @@ const logsRuleTypes = [
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
ML_ANOMALY_DETECTION_RULE_TYPE_ID,
];
export const getLogsFeature = (): KibanaFeatureConfig => {
const logsAlertingFeatures = logsRuleTypes.map((ruleTypeId) => {
const consumers = [LOGS_FEATURE_ID, ALERTING_FEATURE_ID, ...DEPRECATED_ALERTING_CONSUMERS];
const logsAlertingFeatures = logsRuleTypes.map((ruleTypeId) => ({
ruleTypeId,
consumers: [LOGS_FEATURE_ID, ALERTING_FEATURE_ID],
}));
return {
ruleTypeId,
consumers,
};
});
export const LOGS_FEATURE = {
id: LOGS_FEATURE_ID,
name: i18n.translate('xpack.infra.featureRegistry.linkLogsTitle', {
defaultMessage: 'Logs',
}),
order: 700,
category: DEFAULT_APP_CATEGORIES.observability,
scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security],
app: ['infra', 'logs', 'kibana', 'observability-logs-explorer'],
catalogue: ['infralogging', 'logs'],
management: {
insightsAndAlerting: ['triggersActions'],
},
alerting: logsAlertingFeatures,
privileges: {
all: {
app: ['infra', 'logs', 'kibana', 'observability-logs-explorer'],
catalogue: ['infralogging', 'logs'],
api: ['infra', 'rac'],
savedObject: {
all: [infraSourceConfigurationSavedObjectName, logViewSavedObjectName],
read: [],
},
alerting: {
rule: {
all: logsAlertingFeatures,
},
alert: {
all: logsAlertingFeatures,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'configureSource', 'save'],
const LOGS_FEATURE = {
id: LOGS_FEATURE_ID,
name: i18n.translate('xpack.infra.featureRegistry.linkLogsTitle', {
defaultMessage: 'Logs',
}),
order: 700,
category: DEFAULT_APP_CATEGORIES.observability,
scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security],
app: ['infra', 'logs', 'kibana', 'observability-logs-explorer'],
catalogue: ['infralogging', 'logs'],
management: {
insightsAndAlerting: ['triggersActions'],
},
read: {
app: ['infra', 'logs', 'kibana', 'observability-logs-explorer'],
catalogue: ['infralogging', 'logs'],
api: ['infra', 'rac'],
alerting: {
rule: {
read: logsAlertingFeatures,
alerting: logsAlertingFeatures,
privileges: {
all: {
app: ['infra', 'logs', 'kibana', 'observability-logs-explorer'],
catalogue: ['infralogging', 'logs'],
api: ['infra', 'rac'],
savedObject: {
all: [infraSourceConfigurationSavedObjectName, logViewSavedObjectName],
read: [],
},
alert: {
read: logsAlertingFeatures,
alerting: {
rule: {
all: logsAlertingFeatures,
},
alert: {
all: logsAlertingFeatures,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show', 'configureSource', 'save'],
},
management: {
insightsAndAlerting: ['triggersActions'],
read: {
app: ['infra', 'logs', 'kibana', 'observability-logs-explorer'],
catalogue: ['infralogging', 'logs'],
api: ['infra', 'rac'],
alerting: {
rule: {
read: logsAlertingFeatures,
},
alert: {
read: logsAlertingFeatures,
},
},
management: {
insightsAndAlerting: ['triggersActions'],
},
savedObject: {
all: [],
read: [infraSourceConfigurationSavedObjectName, logViewSavedObjectName],
},
ui: ['show'],
},
savedObject: {
all: [],
read: [infraSourceConfigurationSavedObjectName, logViewSavedObjectName],
},
ui: ['show'],
},
},
};
return LOGS_FEATURE;
};

View file

@ -20,7 +20,7 @@ import {
import { type AlertsLocatorParams, alertsLocatorID } from '@kbn/observability-plugin/common';
import { mapValues } from 'lodash';
import { LOGS_FEATURE_ID, METRICS_FEATURE_ID } from '../common/constants';
import { LOGS_FEATURE, METRICS_FEATURE } from './features';
import { getLogsFeature, getMetricsFeature } from './features';
import { registerRoutes } from './infra_server';
import type {
InfraServerPluginSetupDeps,
@ -85,7 +85,6 @@ export class InfraServerPlugin
constructor(context: PluginInitializerContext<InfraConfig>) {
this.config = context.config.get();
this.logger = context.logger.get();
this.logsRules = new RulesService(
LOGS_FEATURE_ID,
LOGS_RULES_ALERT_CONTEXT,
@ -105,7 +104,6 @@ export class InfraServerPlugin
setup(core: InfraPluginCoreSetup, plugins: InfraServerPluginSetupDeps) {
const framework = new KibanaFramework(core, this.config, plugins);
const metricsClient = plugins.metricsDataAccess.client;
metricsClient.setDefaultMetricIndicesHandler(async (options: GetMetricIndicesOptions) => {
const sourceConfiguration = await sources.getInfraSourceConfiguration(
@ -181,8 +179,8 @@ export class InfraServerPlugin
plugins: libsPlugins,
};
plugins.features.registerKibanaFeature(METRICS_FEATURE);
plugins.features.registerKibanaFeature(LOGS_FEATURE);
plugins.features.registerKibanaFeature(getMetricsFeature());
plugins.features.registerKibanaFeature(getLogsFeature());
// Register an handler to retrieve the fallback logView starting from a source configuration
plugins.logsShared.logViews.registerLogViewFallbackHandler(async (sourceId, { soClient }) => {

View file

@ -30,7 +30,6 @@ export const observabilityAlertFeatureIds: ValidFeatureId[] = [
export const observabilityRuleCreationValidConsumers: RuleCreationValidConsumer[] = [
AlertConsumers.INFRASTRUCTURE,
AlertConsumers.LOGS,
AlertConsumers.OBSERVABILITY,
];
export const EventsAsUnit = 'events';

View file

@ -25,11 +25,11 @@ export function RulePage() {
application,
notifications,
charts,
serverless,
settings,
data,
dataViews,
unifiedSearch,
serverless,
actionTypeRegistry,
ruleTypeRegistry,
chrome,
@ -105,7 +105,6 @@ export function RulePage() {
ruleTypeId={ruleTypeId}
validConsumers={observabilityRuleCreationValidConsumers}
multiConsumerSelection={AlertConsumers.LOGS}
isServerless={!!serverless}
onCancel={() => {
if (returnApp && returnPath) {
application.navigateToApp(returnApp, { path: returnPath });

View file

@ -13,18 +13,10 @@ import {
getApiTags as getCasesApiTags,
} from '@kbn/cases-plugin/common';
import { CloudSetup } from '@kbn/cloud-plugin/server';
import {
CoreSetup,
CoreStart,
DEFAULT_APP_CATEGORIES,
Logger,
Plugin,
PluginInitializerContext,
} from '@kbn/core/server';
import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server';
import { DISCOVER_APP_LOCATOR, type DiscoverAppLocatorParams } from '@kbn/discover-plugin/common';
import { FeaturesPluginSetup } from '@kbn/features-plugin/server';
import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server';
import { i18n } from '@kbn/i18n';
import {
RuleRegistryPluginSetupContract,
RuleRegistryPluginStartContract,
@ -33,8 +25,6 @@ import { SharePluginSetup } from '@kbn/share-plugin/server';
import { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server';
import { ALERTING_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import { ObservabilityConfig } from '.';
import { observabilityFeatureId } from '../common';
import {
@ -53,7 +43,6 @@ import { registerRoutes } from './routes/register_routes';
import { threshold } from './saved_objects/threshold';
import { AlertDetailsContextualInsightsService } from './services';
import { uiSettings } from './ui_settings';
import { OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES } from '../common/constants';
import { getCasesFeature } from './features/cases_v1';
import { getCasesFeatureV2 } from './features/cases_v2';
import { getCasesFeatureV3 } from './features/cases_v3';
@ -79,14 +68,6 @@ interface PluginStart {
ruleRegistry: RuleRegistryPluginStartContract;
dashboard: DashboardPluginStart;
}
const alertingFeatures = OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES.map(
(ruleTypeId) => ({
ruleTypeId,
consumers: [observabilityFeatureId, ALERTING_FEATURE_ID],
})
);
export class ObservabilityPlugin
implements Plugin<ObservabilityPluginSetup, void, PluginSetup, PluginStart>
{
@ -130,59 +111,6 @@ export class ObservabilityPlugin
});
}
if (config.createO11yGenericFeatureId) {
plugins.features.registerKibanaFeature({
id: observabilityFeatureId,
name: i18n.translate('xpack.observability.nameFeatureTitle', {
defaultMessage: 'Observability',
}),
order: 1000,
category: DEFAULT_APP_CATEGORIES.observability,
scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security],
app: [observabilityFeatureId],
catalogue: [observabilityFeatureId],
alerting: alertingFeatures,
privileges: {
all: {
app: [observabilityFeatureId],
catalogue: [observabilityFeatureId],
api: ['rac'],
savedObject: {
all: [],
read: [],
},
alerting: {
rule: {
all: alertingFeatures,
},
alert: {
all: alertingFeatures,
},
},
ui: ['read', 'write'],
},
read: {
app: [observabilityFeatureId],
catalogue: [observabilityFeatureId],
api: ['rac'],
savedObject: {
all: [],
read: [],
},
alerting: {
rule: {
read: alertingFeatures,
},
alert: {
read: alertingFeatures,
},
},
ui: ['read'],
},
},
});
}
const { ruleDataService } = plugins.ruleRegistry;
core.savedObjects.registerType(threshold);

View file

@ -17,6 +17,7 @@ describe('updateGlobalNavigation', () => {
describe('when no observability apps are enabled', () => {
it('hides the overview link', () => {
const capabilities = {
logs: { show: false },
navLinks: { apm: false, logs: false, metrics: false, uptime: false },
} as unknown as ApplicationStart['capabilities'];
const deepLinks: AppDeepLink[] = [];
@ -37,6 +38,7 @@ describe('updateGlobalNavigation', () => {
describe('when one observability app is enabled', () => {
it('shows the overview link', () => {
const capabilities = {
logs: { show: true },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown as ApplicationStart['capabilities'];
const deepLinks: AppDeepLink[] = [];
@ -57,6 +59,7 @@ describe('updateGlobalNavigation', () => {
it('shows the cases deep link', () => {
const capabilities = {
[casesFeatureId]: { read_cases: true },
logs: { show: true },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown as ApplicationStart['capabilities'];
@ -93,6 +96,7 @@ describe('updateGlobalNavigation', () => {
it('hides the cases deep link', () => {
const capabilities = {
[casesFeatureId]: { read_cases: false },
logs: { show: true },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown as ApplicationStart['capabilities'];
@ -124,6 +128,7 @@ describe('updateGlobalNavigation', () => {
it('shows the alerts deep link', () => {
const capabilities = {
[casesFeatureId]: { read_cases: true },
logs: { show: true },
navLinks: { apm: true, logs: false, metrics: false, uptime: false },
} as unknown as ApplicationStart['capabilities'];
@ -156,6 +161,43 @@ describe('updateGlobalNavigation', () => {
visibleIn: ['sideNav', 'globalSearch', 'home', 'kibanaOverview'],
});
});
it('shows the alerts deep link for logs', () => {
const capabilities = {
[casesFeatureId]: { read_cases: true },
logs: { show: true },
navLinks: { apm: false, logs: false, metrics: false, uptime: false },
} as unknown as ApplicationStart['capabilities'];
const deepLinks = [
{
id: 'alerts',
title: 'Alerts',
order: 8001,
path: '/alerts',
visibleIn: [],
},
];
const callback = jest.fn();
const updater$ = {
next: (cb: AppUpdater) => callback(cb(app)),
} as unknown as Subject<AppUpdater>;
updateGlobalNavigation({ capabilities, deepLinks, updater$ });
expect(callback).toHaveBeenCalledWith({
deepLinks: [
{
id: 'alerts',
title: 'Alerts',
order: 8001,
path: '/alerts',
visibleIn: ['sideNav', 'globalSearch'],
},
],
visibleIn: ['sideNav', 'globalSearch', 'home', 'kibanaOverview'],
});
});
});
});
});

View file

@ -19,12 +19,17 @@ export function updateGlobalNavigation({
deepLinks: AppDeepLink[];
updater$: Subject<AppUpdater>;
}) {
const { apm, logs, metrics, uptime, slo } = capabilities.navLinks;
const { apm, metrics, uptime, synthetics, slo } = capabilities.navLinks;
/* logs is a special case.
* It is not a nav link but still exists as a
* Kibana feature privilege with attached rule types */
const logs = capabilities.logs?.show;
const someVisible = Object.values({
apm,
logs,
metrics,
uptime,
synthetics,
slo,
}).some((visible) => visible);

View file

@ -315,7 +315,17 @@ export const createNavigationTree = ({
defaultMessage: 'Access',
}),
breadcrumbStatus: 'hidden',
children: [{ link: 'management:api_keys', breadcrumbStatus: 'hidden' }],
children: [
{ link: 'management:api_keys', breadcrumbStatus: 'hidden' },
{ link: 'management:roles', breadcrumbStatus: 'hidden' },
{
cloudLink: 'userAndRoles',
title: i18n.translate(
'xpack.serverlessObservability.navLinks.projectSettings.mngt.usersAndRoles',
{ defaultMessage: 'Manage organization members' }
),
},
],
},
{
title: i18n.translate(
@ -374,10 +384,6 @@ export const createNavigationTree = ({
{
link: 'fleet',
},
{
id: 'cloudLinkUserAndRoles',
cloudLink: 'userAndRoles',
},
{
id: 'cloudLinkBilling',
cloudLink: 'billingAndSub',

View file

@ -19,7 +19,7 @@ import {
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import { i18n } from '@kbn/i18n';
import { AlertsLocatorDefinition, sloFeatureId } from '@kbn/observability-plugin/common';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '@kbn/rule-data-utils';
import { SLO_BURN_RATE_RULE_TYPE_ID, DEPRECATED_ALERTING_CONSUMERS } from '@kbn/rule-data-utils';
import { mapValues } from 'lodash';
import { LockAcquisitionError, LockManagerService } from '@kbn/lock-manager';
import { getSloClientWithRequest } from './client';
@ -81,7 +81,7 @@ export class SLOPlugin
const alertingFeatures = sloRuleTypes.map((ruleTypeId) => ({
ruleTypeId,
consumers: [sloFeatureId, ALERTING_FEATURE_ID],
consumers: [sloFeatureId, ALERTING_FEATURE_ID, ...DEPRECATED_ALERTING_CONSUMERS],
}));
plugins.features.registerKibanaFeature({

View file

@ -12,6 +12,7 @@ import {
SubFeaturePrivilegeGroupType,
} from '@kbn/features-plugin/common';
import { ALERTING_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { DEPRECATED_ALERTING_CONSUMERS } from '@kbn/rule-data-utils';
import { UPTIME_RULE_TYPE_IDS, SYNTHETICS_RULE_TYPE_IDS } from '@kbn/rule-data-utils';
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
import { syntheticsMonitorType, syntheticsParamType } from '../common/types/saved_objects';
@ -32,7 +33,7 @@ const ruleTypes = [...UPTIME_RULE_TYPE_IDS, ...SYNTHETICS_RULE_TYPE_IDS];
const alertingFeatures = ruleTypes.map((ruleTypeId) => ({
ruleTypeId,
consumers: [PLUGIN.ID, ALERTING_FEATURE_ID],
consumers: [PLUGIN.ID, ALERTING_FEATURE_ID, ...DEPRECATED_ALERTING_CONSUMERS],
}));
const elasticManagedLocationsEnabledPrivilege: SubFeaturePrivilegeGroupConfig = {

View file

@ -45,6 +45,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
const samlAuth = getService('samlAuth');
const alertingApi = getService('alertingApi');
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantApi');
const kibanaServer = getService('kibanaServer');
describe('alerts', function () {
// LLM Proxy is not yet support in MKI: https://github.com/elastic/obs-ai-assistant-team/issues/199
@ -62,6 +63,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
const end = 'now';
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
internalReqHeader = samlAuth.getInternalRequestHeader();
roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('editor');
@ -136,6 +138,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
await deleteRules({ getService, roleAuthc, internalReqHeader });
await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc);
await kibanaServer.savedObjects.cleanStandardList();
});
it('should execute the function without any errors', async () => {

View file

@ -4,11 +4,18 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { v4 as uuidv4 } from 'uuid';
import { cleanup, Dataset, generate, PartialConfig } from '@kbn/data-forge';
import { RoleCredentials, InternalRequestHeader } from '@kbn/ftr-common-functional-services';
import expect from '@kbn/expect';
import { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context';
import { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
const RULE_TYPE_ID = 'slo.rules.burnRate';
const DATA_VIEW = 'kbn-data-forge-fake_hosts.fake_hosts-*';
const RULE_ALERT_INDEX = '.alerts-observability.slo.alerts-default';
const RULE_ALERT_INDEX_PATTERN = '.alerts-observability.slo.alerts-*';
const ALERT_ACTION_INDEX = 'alert-action-slo';
const DATA_VIEW_ID = 'data-view-id';
export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
const esClient = getService('es');
@ -21,24 +28,33 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
const sloApi = getService('sloApi');
const config = getService('config');
const isServerless = config.get('serverless');
const kibanaServer = getService('kibanaServer');
const expectedConsumer = isServerless ? 'observability' : 'slo';
describe('Burn rate rule', () => {
const RULE_TYPE_ID = 'slo.rules.burnRate';
const DATA_VIEW = 'kbn-data-forge-fake_hosts.fake_hosts-*';
const RULE_ALERT_INDEX = '.alerts-observability.slo.alerts-default';
const ALERT_ACTION_INDEX = 'alert-action-slo';
const DATA_VIEW_ID = 'data-view-id';
let dataForgeConfig: PartialConfig;
let dataForgeIndices: string[];
let actionId: string;
let ruleId: string;
let dependencyRuleId: string;
let editorRoleAuthc: RoleCredentials;
let adminRoleAuthc: RoleCredentials;
let currentRoleAuthc: RoleCredentials;
let internalHeaders: InternalRequestHeader;
let sloId: string;
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX_PATTERN,
query: {
match_all: {},
},
conflicts: 'proceed',
});
editorRoleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('editor');
adminRoleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('admin');
currentRoleAuthc = isServerless ? editorRoleAuthc : adminRoleAuthc;
internalHeaders = samlAuth.getInternalRequestHeader();
dataForgeConfig = {
schedule: [
@ -66,7 +82,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
docCountTarget: 360,
});
await dataViewApi.create({
roleAuthc: adminRoleAuthc,
roleAuthc: currentRoleAuthc,
name: DATA_VIEW,
id: DATA_VIEW_ID,
title: DATA_VIEW,
@ -76,53 +92,67 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
after(async () => {
await supertestWithoutAuth
.delete(`/api/alerting/rule/${ruleId}`)
.set(adminRoleAuthc.apiKeyHeader)
.set(currentRoleAuthc.apiKeyHeader)
.set(internalHeaders);
await supertestWithoutAuth
.delete(`/api/actions/connector/${actionId}`)
.set(adminRoleAuthc.apiKeyHeader)
.set(currentRoleAuthc.apiKeyHeader)
.set(internalHeaders);
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX,
query: {
bool: {
should: [
{ term: { 'kibana.alert.rule.uuid': ruleId } },
{ term: { 'kibana.alert.rule.uuid': dependencyRuleId } },
],
if (ruleId) {
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX,
query: {
bool: {
should: [
{ term: { 'kibana.alert.rule.uuid': ruleId } },
...(dependencyRuleId
? [{ term: { 'kibana.alert.rule.uuid': dependencyRuleId } }]
: []),
],
},
},
},
conflicts: 'proceed',
});
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'rule.id': ruleId } },
conflicts: 'proceed',
});
conflicts: 'proceed',
});
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'rule.id': ruleId } },
conflicts: 'proceed',
});
}
await dataViewApi.delete({
roleAuthc: adminRoleAuthc,
roleAuthc: currentRoleAuthc,
id: DATA_VIEW_ID,
});
await supertestWithoutAuth
.delete('/api/observability/slos/my-custom-id')
.set(adminRoleAuthc.apiKeyHeader)
.delete(`/api/observability/slos/${sloId}`)
.set(currentRoleAuthc.apiKeyHeader)
.set(internalHeaders);
await esDeleteAllIndices([ALERT_ACTION_INDEX, ...dataForgeIndices]);
await cleanup({ client: esClient, config: dataForgeConfig, logger });
await samlAuth.invalidateM2mApiKeyWithRoleScope(adminRoleAuthc);
await samlAuth.invalidateM2mApiKeyWithRoleScope(currentRoleAuthc);
await kibanaServer.savedObjects.cleanStandardList();
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX_PATTERN,
query: {
match_all: {},
},
conflicts: 'proceed',
});
});
describe('Rule creation', () => {
describe('Rule creation', function () {
it('creates rule successfully', async () => {
sloId = uuidv4();
actionId = await alertingApi.createIndexConnector({
roleAuthc: adminRoleAuthc,
roleAuthc: currentRoleAuthc,
name: 'Index Connector: Slo Burn rate API test',
indexName: ALERT_ACTION_INDEX,
});
await sloApi.create(
{
id: 'my-custom-id',
id: sloId,
name: 'my custom name',
description: 'my custom description',
indicator: {
@ -144,11 +174,11 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
},
groupBy: '*',
},
adminRoleAuthc
currentRoleAuthc
);
const dependencyRule = await alertingApi.createRule({
roleAuthc: adminRoleAuthc,
roleAuthc: currentRoleAuthc,
tags: ['observability'],
consumer: expectedConsumer,
name: 'SLO Burn Rate rule - Dependency',
@ -157,7 +187,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
interval: '1m',
},
params: {
sloId: 'my-custom-id',
sloId,
windows: [
{
id: '1',
@ -222,7 +252,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
dependencyRuleId = dependencyRule.id;
const createdRule = await alertingApi.createRule({
roleAuthc: adminRoleAuthc,
roleAuthc: currentRoleAuthc,
tags: ['observability'],
consumer: expectedConsumer,
name: 'SLO Burn Rate rule',
@ -231,7 +261,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
interval: '1m',
},
params: {
sloId: 'my-custom-id',
sloId,
dependencies: [
{
ruleId: dependencyRule.id,
@ -305,7 +335,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
it('should be active', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: adminRoleAuthc,
roleAuthc: currentRoleAuthc,
ruleId,
expectedStatus: 'active',
});
@ -325,10 +355,88 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(adminRoleAuthc, ruleId);
const match = await alertingApi.findInRules(currentRoleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(expectedConsumer);
});
});
});
}
export const getSloBurnRateRuleConfiguration = ({
sloId,
consumer,
}: {
sloId: string;
consumer: string;
}) => ({
tags: ['observability'],
consumer,
name: 'SLO Burn Rate rule',
ruleTypeId: RULE_TYPE_ID,
schedule: {
interval: '1m',
},
params: {
sloId,
windows: [
{
id: '1',
actionGroup: 'slo.burnRate.alert',
burnRateThreshold: 3.36,
maxBurnRateThreshold: 720,
longWindow: {
value: 1,
unit: 'h',
},
shortWindow: {
value: 5,
unit: 'm',
},
},
{
id: '2',
actionGroup: 'slo.burnRate.high',
burnRateThreshold: 1.4,
maxBurnRateThreshold: 120,
longWindow: {
value: 6,
unit: 'h',
},
shortWindow: {
value: 30,
unit: 'm',
},
},
{
id: '3',
actionGroup: 'slo.burnRate.medium',
burnRateThreshold: 0.7,
maxBurnRateThreshold: 30,
longWindow: {
value: 24,
unit: 'h',
},
shortWindow: {
value: 120,
unit: 'm',
},
},
{
id: '4',
actionGroup: 'slo.burnRate.low',
burnRateThreshold: 0.234,
maxBurnRateThreshold: 10,
longWindow: {
value: 72,
unit: 'h',
},
shortWindow: {
value: 360,
unit: 'm',
},
},
],
},
actions: [],
});

View file

@ -0,0 +1,691 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { cleanup, Dataset, generate, PartialConfig } from '@kbn/data-forge';
import { RoleCredentials, InternalRequestHeader } from '@kbn/ftr-common-functional-services';
import expect from '@kbn/expect';
import { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
const RULE_TYPE_ID = 'slo.rules.burnRate';
const DATA_VIEW = 'kbn-data-forge-fake_hosts.fake_hosts-*';
const RULE_ALERT_INDEX = '.alerts-observability.slo.alerts-default';
const RULE_ALERT_INDEX_PATTERN = '.alerts-observability.slo.alerts-*';
const ALERT_ACTION_INDEX = 'alert-action-slo';
const DATA_VIEW_ID = 'data-view-id';
export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
const esClient = getService('es');
const esDeleteAllIndices = getService('esDeleteAllIndices');
const samlAuth = getService('samlAuth');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const logger = getService('log');
const alertingApi = getService('alertingApi');
const dataViewApi = getService('dataViewApi');
const sloApi = getService('sloApi');
const config = getService('config');
const isServerless = config.get('serverless');
const kibanaServer = getService('kibanaServer');
describe('Burn rate rule', () => {
let dataForgeConfig: PartialConfig;
let dataForgeIndices: string[];
let actionId: string;
let ruleId: string;
let dependencyRuleId: string;
let editorRoleAuthc: RoleCredentials;
let adminRoleAuthc: RoleCredentials;
let currentRoleAuthc: RoleCredentials;
let internalHeaders: InternalRequestHeader;
let sloId: string;
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX_PATTERN,
query: {
match_all: {},
},
conflicts: 'proceed',
});
editorRoleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('editor');
adminRoleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('admin');
currentRoleAuthc = isServerless ? editorRoleAuthc : adminRoleAuthc;
internalHeaders = samlAuth.getInternalRequestHeader();
dataForgeConfig = {
schedule: [
{
template: 'good',
start: 'now-15m',
end: 'now+5m',
metrics: [
{ name: 'system.cpu.user.pct', method: 'linear', start: 2.5, end: 2.5 },
{ name: 'system.cpu.total.pct', method: 'linear', start: 0.5, end: 0.5 },
{ name: 'system.cpu.total.norm.pct', method: 'linear', start: 0.8, end: 0.8 },
],
},
],
indexing: {
dataset: 'fake_hosts' as Dataset,
eventsPerCycle: 1,
interval: 10000,
alignEventsToInterval: true,
},
};
dataForgeIndices = await generate({ client: esClient, config: dataForgeConfig, logger });
await alertingApi.waitForDocumentInIndex({
indexName: DATA_VIEW,
docCountTarget: 360,
});
await dataViewApi.create({
roleAuthc: currentRoleAuthc,
name: DATA_VIEW,
id: DATA_VIEW_ID,
title: DATA_VIEW,
});
});
after(async () => {
await supertestWithoutAuth
.delete(`/api/alerting/rule/${ruleId}`)
.set(currentRoleAuthc.apiKeyHeader)
.set(internalHeaders);
await supertestWithoutAuth
.delete(`/api/actions/connector/${actionId}`)
.set(currentRoleAuthc.apiKeyHeader)
.set(internalHeaders);
if (ruleId) {
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX,
query: {
bool: {
should: [
{ term: { 'kibana.alert.rule.uuid': ruleId } },
...(dependencyRuleId
? [{ term: { 'kibana.alert.rule.uuid': dependencyRuleId } }]
: []),
],
},
},
conflicts: 'proceed',
});
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'rule.id': ruleId } },
conflicts: 'proceed',
});
}
await dataViewApi.delete({
roleAuthc: currentRoleAuthc,
id: DATA_VIEW_ID,
});
await supertestWithoutAuth
.delete(`/api/observability/slos/${sloId}`)
.set(currentRoleAuthc.apiKeyHeader)
.set(internalHeaders);
await esDeleteAllIndices([ALERT_ACTION_INDEX, ...dataForgeIndices]);
await cleanup({ client: esClient, config: dataForgeConfig, logger });
await samlAuth.invalidateM2mApiKeyWithRoleScope(currentRoleAuthc);
await kibanaServer.savedObjects.cleanStandardList();
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX_PATTERN,
query: {
match_all: {},
},
conflicts: 'proceed',
});
});
describe('Burn rate rule - slo consumer', function () {
this.tags(['skipMKI']);
const consumer = 'slo';
it('creates rule successfully', async () => {
sloId = uuidv4();
actionId = await alertingApi.createIndexConnector({
roleAuthc: currentRoleAuthc,
name: 'Index Connector: Slo Burn rate API test',
indexName: ALERT_ACTION_INDEX,
});
await sloApi.create(
{
id: sloId,
name: 'my custom name',
description: 'my custom description',
indicator: {
type: 'sli.kql.custom',
params: {
index: DATA_VIEW,
good: 'system.cpu.total.norm.pct > 1',
total: 'system.cpu.total.norm.pct: *',
timestampField: '@timestamp',
},
},
timeWindow: {
duration: '7d',
type: 'rolling',
},
budgetingMethod: 'occurrences',
objective: {
target: 0.999,
},
groupBy: '*',
},
currentRoleAuthc
);
// @ts-ignore
const createdRule = await alertingApi.createRule({
roleAuthc: currentRoleAuthc,
...getSloBurnRateRuleConfiguration({
sloId,
consumer,
}),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(currentRoleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: currentRoleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should be active and visible from slo only role', async () => {
await samlAuth.setCustomRole(ROLES.slo_only);
const sloOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: sloOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(sloOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Burn rate rule - consumer alerts', function () {
this.tags(['skipMKI']);
const consumer = 'alerts';
it('creates rule successfully', async () => {
sloId = uuidv4();
actionId = await alertingApi.createIndexConnector({
roleAuthc: currentRoleAuthc,
name: 'Index Connector: Slo Burn rate API test',
indexName: ALERT_ACTION_INDEX,
});
await sloApi.create(
{
id: sloId,
name: 'my custom name',
description: 'my custom description',
indicator: {
type: 'sli.kql.custom',
params: {
index: DATA_VIEW,
good: 'system.cpu.total.norm.pct > 1',
total: 'system.cpu.total.norm.pct: *',
timestampField: '@timestamp',
},
},
timeWindow: {
duration: '7d',
type: 'rolling',
},
budgetingMethod: 'occurrences',
objective: {
target: 0.999,
},
groupBy: '*',
},
currentRoleAuthc
);
// @ts-ignore
const createdRule = await alertingApi.createRule({
roleAuthc: currentRoleAuthc,
...getSloBurnRateRuleConfiguration({
sloId,
consumer,
}),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(currentRoleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: currentRoleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should be active and visible from slo only role', async () => {
await samlAuth.setCustomRole(ROLES.slo_only);
const sloOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: sloOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(sloOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Burn rate rule - consumer observability', function () {
this.tags(['skipMKI']);
const consumer = 'observability';
it('creates rule successfully', async () => {
sloId = uuidv4();
actionId = await alertingApi.createIndexConnector({
roleAuthc: currentRoleAuthc,
name: 'Index Connector: Slo Burn rate API test',
indexName: ALERT_ACTION_INDEX,
});
await sloApi.create(
{
id: sloId,
name: 'my custom name',
description: 'my custom description',
indicator: {
type: 'sli.kql.custom',
params: {
index: DATA_VIEW,
good: 'system.cpu.total.norm.pct > 1',
total: 'system.cpu.total.norm.pct: *',
timestampField: '@timestamp',
},
},
timeWindow: {
duration: '7d',
type: 'rolling',
},
budgetingMethod: 'occurrences',
objective: {
target: 0.999,
},
groupBy: '*',
},
currentRoleAuthc
);
// @ts-ignore
const createdRule = await alertingApi.createRule({
roleAuthc: currentRoleAuthc,
...getSloBurnRateRuleConfiguration({
sloId,
consumer,
}),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(currentRoleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: currentRoleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should be active and visible from slo only role', async () => {
await samlAuth.setCustomRole(ROLES.slo_only);
const sloOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: sloOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(sloOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Burn rate rule - can create - slo only role', function () {
this.tags(['skipMKI']);
const consumer = 'slo';
let sloOnlyRole: RoleCredentials;
it('creates rule successfully', async () => {
sloId = uuidv4();
await samlAuth.setCustomRole(ROLES.slo_only);
sloOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
await sloApi.create(
{
id: sloId,
name: 'my custom name',
description: 'my custom description',
indicator: {
type: 'sli.kql.custom',
params: {
index: DATA_VIEW,
good: 'system.cpu.total.norm.pct > 1',
total: 'system.cpu.total.norm.pct: *',
timestampField: '@timestamp',
},
},
timeWindow: {
duration: '7d',
type: 'rolling',
},
budgetingMethod: 'occurrences',
objective: {
target: 0.999,
},
groupBy: '*',
},
currentRoleAuthc
);
// @ts-ignore
const createdRule = await alertingApi.createRule({
roleAuthc: currentRoleAuthc,
...getSloBurnRateRuleConfiguration({
sloId,
consumer,
}),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(currentRoleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from slo role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: sloOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(sloOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Burn rate rule - can NOT create - synthetics only role', function () {
this.tags(['skipMKI']);
const consumer = 'slo';
let syntheticsOnlyRole: RoleCredentials;
it('creates rule successfully', async () => {
sloId = uuidv4();
await samlAuth.setCustomRole(ROLES.synthetics_only);
syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
// create SLO first with editor role
await sloApi.create(
{
id: sloId,
name: 'my custom name',
description: 'my custom description',
indicator: {
type: 'sli.kql.custom',
params: {
index: DATA_VIEW,
good: 'system.cpu.total.norm.pct > 1',
total: 'system.cpu.total.norm.pct: *',
timestampField: '@timestamp',
},
},
timeWindow: {
duration: '7d',
type: 'rolling',
},
budgetingMethod: 'occurrences',
objective: {
target: 0.999,
},
groupBy: '*',
},
currentRoleAuthc
);
// verify that synthetics only role cannot create the rule
// @ts-ignore
const respponse = await alertingApi.createRule({
roleAuthc: syntheticsOnlyRole,
...getSloBurnRateRuleConfiguration({
sloId,
consumer,
}),
});
expect(respponse.statusCode).to.be(403);
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
});
}
export const ROLES = {
slo_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
slo: ['all'],
},
},
],
},
synthetics_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
uptime: ['all'],
},
},
],
},
};
export const getSloBurnRateRuleConfiguration = ({
sloId,
consumer,
}: {
sloId: string;
consumer: string;
}) => ({
tags: ['observability'],
consumer,
name: 'SLO Burn Rate rule',
ruleTypeId: RULE_TYPE_ID,
schedule: {
interval: '1m',
},
params: {
sloId,
windows: [
{
id: '1',
actionGroup: 'slo.burnRate.alert',
burnRateThreshold: 3.36,
maxBurnRateThreshold: 720,
longWindow: {
value: 1,
unit: 'h',
},
shortWindow: {
value: 5,
unit: 'm',
},
},
{
id: '2',
actionGroup: 'slo.burnRate.high',
burnRateThreshold: 1.4,
maxBurnRateThreshold: 120,
longWindow: {
value: 6,
unit: 'h',
},
shortWindow: {
value: 30,
unit: 'm',
},
},
{
id: '3',
actionGroup: 'slo.burnRate.medium',
burnRateThreshold: 0.7,
maxBurnRateThreshold: 30,
longWindow: {
value: 24,
unit: 'h',
},
shortWindow: {
value: 120,
unit: 'm',
},
},
{
id: '4',
actionGroup: 'slo.burnRate.low',
burnRateThreshold: 0.234,
maxBurnRateThreshold: 10,
longWindow: {
value: 72,
unit: 'h',
},
shortWindow: {
value: 360,
unit: 'm',
},
},
],
},
actions: [],
});

View file

@ -0,0 +1,16 @@
/*
* 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 { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) {
describe('SLO Burn rate rule', () => {
loadTestFile(require.resolve('./burn_rate_rule'));
// movd to feature flag config until custom roles are supported in serverless
// loadTestFile(require.resolve('./consumers_and_privileges'));
});
}

View file

@ -0,0 +1,742 @@
/*
* 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 '@kbn/expect';
import { cleanup, generate, Dataset, PartialConfig } from '@kbn/data-forge';
import { Aggregators } from '@kbn/observability-plugin/common/custom_threshold_rule/types';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils';
import { COMPARATORS } from '@kbn/alerting-comparators';
import type { InternalRequestHeader, RoleCredentials } from '@kbn/ftr-common-functional-services';
import { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
const esClient = getService('es');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esDeleteAllIndices = getService('esDeleteAllIndices');
const logger = getService('log');
const alertingApi = getService('alertingApi');
const dataViewApi = getService('dataViewApi');
const samlAuth = getService('samlAuth');
const kibanaServer = getService('kibanaServer');
let roleAuthc: RoleCredentials;
let internalReqHeader: InternalRequestHeader;
const config = getService('config');
const isServerless = config.get('serverless');
describe('Custom Threshold Rule - consumers and priviledges', function () {
// skip until custom roles are supported in serverless
this.tags(['skipMKI']);
const CUSTOM_THRESHOLD_RULE_ALERT_INDEX_PATTERN = '.alerts-observability.threshold.alerts-*';
const ALERT_ACTION_INDEX = 'alert-action-threshold';
const DATA_VIEW = 'kbn-data-forge-fake_hosts.fake_hosts-*';
const DATA_VIEW_ID = 'data-view-id';
const DATA_VIEW_NAME = 'data-view-name';
let dataForgeConfig: PartialConfig;
let dataForgeIndices: string[];
let ruleId: string;
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await esClient.deleteByQuery({
index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX_PATTERN,
query: { match_all: {} },
conflicts: 'proceed',
});
roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('editor');
internalReqHeader = samlAuth.getInternalRequestHeader();
dataForgeConfig = {
schedule: [
{
template: 'good',
start: 'now-10m',
end: 'now+5m',
metrics: [
{ name: 'system.cpu.user.pct', method: 'linear', start: 2.5, end: 2.5 },
{ name: 'system.cpu.total.pct', method: 'linear', start: 0.5, end: 0.5 },
{ name: 'system.cpu.total.norm.pct', method: 'linear', start: 0.8, end: 0.8 },
],
},
],
indexing: {
dataset: 'fake_hosts' as Dataset,
eventsPerCycle: 1,
interval: 60000,
alignEventsToInterval: true,
},
};
dataForgeIndices = await generate({ client: esClient, config: dataForgeConfig, logger });
await alertingApi.waitForDocumentInIndex({
indexName: dataForgeIndices.join(','),
docCountTarget: 45,
});
await dataViewApi.create({
name: DATA_VIEW_NAME,
id: DATA_VIEW_ID,
title: DATA_VIEW,
roleAuthc,
});
});
after(async () => {
await supertestWithoutAuth
.delete(`/api/alerting/rule/${ruleId}`)
.set(roleAuthc.apiKeyHeader)
.set(internalReqHeader);
if (ruleId) {
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'rule.id': ruleId } },
conflicts: 'proceed',
});
}
await dataViewApi.delete({
id: DATA_VIEW_ID,
roleAuthc,
});
await esDeleteAllIndices([ALERT_ACTION_INDEX, ...dataForgeIndices]);
await cleanup({ client: esClient, config: dataForgeConfig, logger });
await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc);
await kibanaServer.savedObjects.cleanStandardList();
await esClient.deleteByQuery({
index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX_PATTERN,
query: { match_all: {} },
conflicts: 'proceed',
});
});
describe('Custom threshold - Rule visibility - consumer observability', () => {
const consumer = 'observability';
it('creates rule successfully', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(roleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should be active and visible from infra only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
const infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(infraOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should be active and visible from logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
const logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(logsOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Custom threshold - Rule visibility - consumer alerts', () => {
const consumer = 'alerts';
it('creates rule successfully', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(roleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should be active and visible from infra only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
const infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should be active and visible from logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
const logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should NOT visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
});
});
describe('Custom threshold - Rule visibility - consumer logs', () => {
const consumer = 'logs';
it('creates rule successfully', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(roleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should be active and visible from logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
const logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(logsOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from infra only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
const infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(infraOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Custom threshold - Rule visibility - consumer infrastructure', () => {
const consumer = 'infrastructure';
it('creates rule successfully', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(roleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should be active and visible from infra only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
const infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(infraOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
const logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(logsOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Custom threshold - Rule execution - consumer stackAlerts', () => {
const consumer = 'stackAlerts';
if (isServerless) {
it('is forbidden', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
expect(createdRule.statusCode).to.be(403);
});
} else {
it('creates rule successfully', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(roleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should NOT be visible from logs role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
const logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(logsOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from infra role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
const infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(infraOnlyRole);
await samlAuth.deleteCustomRole();
});
}
it('should NOT visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Custom threshold - Rule execution - consumer notavalidconsumer', () => {
const consumer = 'notavalidconsumer';
it('creates rule successfully', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
expect(createdRule.statusCode).to.be(403);
expect(createdRule.message).to.be(
'Unauthorized by "notavalidconsumer" to create "observability.rules.custom_threshold" rule'
);
});
});
describe('Custom threshold - Rule execution - consumer undefined', function () {
const consumer = undefined;
it('creates rule successfully', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc,
// @ts-ignore
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
expect(createdRule.statusCode).to.be(400);
expect(createdRule.message).to.be(
'[request body.consumer]: expected value of type [string] but got [undefined]'
);
});
});
describe('Custom threshold - Rule creation - logs only role', function () {
const consumer = 'logs';
let logsOnlyRole: RoleCredentials;
it('creates rule successfully', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const createdRule = await alertingApi.createRule({
roleAuthc: logsOnlyRole,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(logsOnlyRole, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from logs only role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('cannot create a rule with infrastructure consumer', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc: logsOnlyRole,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer: 'infrastructure' }),
});
expect(createdRule.statusCode).to.be(403);
await samlAuth.invalidateM2mApiKeyWithRoleScope(logsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Custom threshold - Rule creation - infra only role', function () {
const consumer = 'infrastructure';
let infraOnlyRole: RoleCredentials;
it('creates rule successfully', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const createdRule = await alertingApi.createRule({
roleAuthc: infraOnlyRole,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(roleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from logs only role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('cannot create a rule with logs consumer', async () => {
const createdRule = await alertingApi.createRule({
roleAuthc: infraOnlyRole,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer: 'logs' }),
});
expect(createdRule.statusCode).to.be(403);
await samlAuth.invalidateM2mApiKeyWithRoleScope(infraOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Custom threshold - rule cannot be created - synthetics only role', function () {
const consumer = 'logs';
it('creates rule successfully', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const response = await alertingApi.createRule({
roleAuthc: syntheticsOnlyRole,
...getRuleConfiguration({ dataViewId: DATA_VIEW_ID, consumer }),
});
expect(response.statusCode).to.be(403);
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
});
}
export const ROLES = {
infra_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
indexPatterns: ['all'],
infrastructure: ['all'],
},
},
],
},
logs_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
indexPatterns: ['all'],
logs: ['all'],
},
},
],
},
synthetics_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
uptime: ['all'],
},
},
],
},
};
const getRuleConfiguration = ({
consumer,
dataViewId,
}: {
consumer: string;
dataViewId: string;
}) => ({
tags: ['observability'],
consumer,
name: 'Threshold rule',
ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
params: {
criteria: [
{
comparator: COMPARATORS.NOT_BETWEEN,
threshold: [1, 2],
timeSize: 1,
timeUnit: 'm' as const,
metrics: [{ name: 'A', filter: 'container.id:*', aggType: Aggregators.COUNT }],
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: {
query: {
query: 'host.name:*',
language: 'kuery',
},
index: dataViewId,
},
},
});

View file

@ -18,5 +18,7 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext)
loadTestFile(require.resolve('./group_by_fired'));
loadTestFile(require.resolve('./p99_pct_fired'));
loadTestFile(require.resolve('./rate_bytes_fired'));
// moved to feature flag config until custom roles in serverless are supported
// loadTestFile(require.resolve('./consumers_and_privileges'));
});
}

View file

@ -0,0 +1,491 @@
/*
* 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 '@kbn/expect';
import { RoleCredentials, InternalRequestHeader } from '@kbn/ftr-common-functional-services';
import { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
const RULE_TYPE_ID = '.es-query';
export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
const esClient = getService('es');
const samlAuth = getService('samlAuth');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esDeleteAllIndices = getService('esDeleteAllIndices');
const alertingApi = getService('alertingApi');
const config = getService('config');
const kibanaServer = getService('kibanaServer');
const isServerless = config.get('serverless');
let editorRoleAuthc: RoleCredentials;
let internalReqHeader: InternalRequestHeader;
describe('Query DSL - Consumers and privileges', function () {
// skip until custom roles are supported in serverless
this.tags(['skipInMKI']);
const ALERT_ACTION_INDEX = 'alert-action-es-query';
const RULE_ALERT_INDEX = '.alerts-stack.alerts-default';
let ruleId: string;
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX,
query: { match_all: {} },
conflicts: 'proceed',
});
editorRoleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('editor');
internalReqHeader = samlAuth.getInternalRequestHeader();
});
after(async () => {
await supertestWithoutAuth
.delete(`/api/alerting/rule/${ruleId}`)
.set(editorRoleAuthc.apiKeyHeader)
.set(internalReqHeader);
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX,
query: { match_all: {} },
conflicts: 'proceed',
});
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'rule.id': ruleId } },
conflicts: 'proceed',
});
await esDeleteAllIndices([ALERT_ACTION_INDEX]);
await samlAuth.invalidateM2mApiKeyWithRoleScope(editorRoleAuthc);
await kibanaServer.savedObjects.cleanStandardList();
});
describe('Rule creation - logs consumer', () => {
const consumer = 'logs';
it('creates rule successfully', async () => {
const createdRule = await alertingApi.helpers.createEsQueryRule({
roleAuthc: editorRoleAuthc,
...getESQueryRuleConfiguration({ consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should be active', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: editorRoleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(editorRoleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
const logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(logsOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from infra only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
const infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(infraOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Rule creation - infrastructure consumer', () => {
const consumer = 'infrastructure';
it('creates rule successfully', async () => {
const createdRule = await alertingApi.helpers.createEsQueryRule({
roleAuthc: editorRoleAuthc,
...getESQueryRuleConfiguration({ consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should be active', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: editorRoleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(editorRoleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from infrastructure only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
const infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(infraOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
const logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(logsOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Rule creation - observability consumer', () => {
const consumer = 'observability';
it('creates rule successfully', async () => {
const createdRule = await alertingApi.helpers.createEsQueryRule({
roleAuthc: editorRoleAuthc,
...getESQueryRuleConfiguration({ consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should be active and visible from editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: editorRoleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(editorRoleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
const logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(logsOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should be active and visible from infra only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
const infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
await samlAuth.invalidateM2mApiKeyWithRoleScope(infraOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
describe('Rule creation - stackAlerts consumer', () => {
const consumer = 'stackAlerts';
if (isServerless) {
it('is forbidden', async () => {
await alertingApi.helpers.createEsQueryRule({
roleAuthc: editorRoleAuthc,
...getESQueryRuleConfiguration({ consumer }),
expectedStatusCode: 403,
});
});
} else {
it('creates rule successfully', async () => {
const createdRule = await alertingApi.helpers.createEsQueryRule({
roleAuthc: editorRoleAuthc,
...getESQueryRuleConfiguration({ consumer }),
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should find the created rule with correct information about the consumer', async () => {
const match = await alertingApi.findInRules(editorRoleAuthc, ruleId);
expect(match).not.to.be(undefined);
expect(match.consumer).to.be(consumer);
});
it('should be active and visible from the editor role', async () => {
const executionStatus = await alertingApi.waitForRuleStatus({
roleAuthc: editorRoleAuthc,
ruleId,
expectedStatus: 'active',
});
expect(executionStatus).to.be('active');
});
it('should NOT be visible from logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
const logsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: logsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(logsOnlyRole);
await samlAuth.deleteCustomRole();
});
it('should NOT be visible from infra only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
const infraOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: infraOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(infraOnlyRole);
await samlAuth.deleteCustomRole();
});
}
it('should NOT be visible from synthetics only role', async () => {
await samlAuth.setCustomRole(ROLES.synthetics_only);
const syntheticsOnlyRole = await samlAuth.createM2mApiKeyWithCustomRoleScope();
try {
await alertingApi.waitForRuleStatus({
roleAuthc: syntheticsOnlyRole,
ruleId,
expectedStatus: 'active',
timeout: 1000 * 3,
});
throw new Error('Expected rule to not be visible, but it was visible');
} catch (error) {
expect(error.message).to.contain('timeout');
}
await samlAuth.invalidateM2mApiKeyWithRoleScope(syntheticsOnlyRole);
await samlAuth.deleteCustomRole();
});
});
});
}
export const ROLES = {
infra_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
infrastructure: ['all'],
},
},
],
},
logs_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
logs: ['all'],
},
},
],
},
synthetics_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
uptime: ['all'],
},
},
],
},
};
export const getESQueryRuleConfiguration = ({ consumer }: { consumer: string }) => ({
consumer,
name: 'always fire',
ruleTypeId: RULE_TYPE_ID,
params: {
size: 100,
thresholdComparator: '>',
threshold: [-1],
index: ['alert-test-data'],
timeField: 'date',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 20,
timeWindowUnit: 's',
},
actions: [],
});

View file

@ -11,5 +11,7 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext)
describe('ElasticSearch query rule', () => {
loadTestFile(require.resolve('./query_dsl'));
loadTestFile(require.resolve('./query_dsl_with_group_by'));
// movd to feature flag config until custom roles are supported in serverless
// loadTestFile(require.resolve('./consumers_and_privileges'));
});
}

View file

@ -9,7 +9,7 @@ import { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_cont
export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) {
describe('Observability Alerting', () => {
loadTestFile(require.resolve('./burn_rate_rule'));
loadTestFile(require.resolve('./burn_rate'));
loadTestFile(require.resolve('./es_query'));
loadTestFile(require.resolve('./custom_threshold'));
});

View file

@ -108,12 +108,9 @@ export function createServerlessFeatureFlagTestConfig<T extends DeploymentAgnost
...svlSharedConfig.get('esTestCluster'),
serverArgs: [
...svlSharedConfig.get('esTestCluster.serverArgs'),
// custom native roles are enabled only for search and security projects
...(options.serverlessProject !== 'oblt'
? ['xpack.security.authc.native_roles.enabled=true']
: []),
...esServerArgsFromController[options.serverlessProject],
...(options.esServerArgs || []),
'xpack.security.authc.native_roles.enabled=true',
],
},
kbnTestServer: {
@ -122,6 +119,8 @@ export function createServerlessFeatureFlagTestConfig<T extends DeploymentAgnost
...svlSharedConfig.get('kbnTestServer.serverArgs'),
...kbnServerArgsFromController[options.serverlessProject],
`--serverless=${options.serverlessProject}`,
// Enable custom roles
'--xpack.security.roleManagementEnabled=true',
...(options.serverlessProject === 'oblt'
? [
// defined in MKI control plane. Necessary for Synthetics app testing

View file

@ -7,12 +7,21 @@
import { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) {
describe('Stateful Observability - Deployment-agnostic Synthetics Alerting API integration tests', function () {
describe('Serverless Observability feature flag testing - Deployment-agnostic API integration tests', function () {
loadTestFile(
require.resolve('../../apis/observability/alerting/synthetics/synthetics_default_rule')
);
loadTestFile(
require.resolve('../../apis/observability/alerting/synthetics/custom_status_rule')
);
loadTestFile(
require.resolve('../../apis/observability/alerting/custom_threshold/consumers_and_privileges')
);
loadTestFile(
require.resolve('../../apis/observability/alerting/es_query/consumers_and_privileges')
);
loadTestFile(
require.resolve('../../apis/observability/alerting/burn_rate/consumers_and_privileges')
);
});
}

View file

@ -13,7 +13,7 @@ export default createServerlessFeatureFlagTestConfig({
'--xpack.actions.preconfigured',
'--xpack.alerting.rules.minimumScheduleInterval.value="1s"',
],
testFiles: [require.resolve('./oblt.synthetics.index.ts')],
testFiles: [require.resolve('./oblt.index.ts')],
junit: {
reportName: 'Serverless Observability - Deployment-agnostic Feature Flag API Integration Tests',
},

View file

@ -7,12 +7,21 @@
import { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) {
describe('Serverless Observability - Deployment-agnostic Synthetics Alerting API integration tests', function () {
describe('Stateful Observability feature flag testing - Deployment-agnostic API integration tests', function () {
loadTestFile(
require.resolve('../../apis/observability/alerting/synthetics/synthetics_default_rule')
);
loadTestFile(
require.resolve('../../apis/observability/alerting/synthetics/custom_status_rule')
);
loadTestFile(
require.resolve('../../apis/observability/alerting/custom_threshold/consumers_and_privileges')
);
loadTestFile(
require.resolve('../../apis/observability/alerting/es_query/consumers_and_privileges')
);
loadTestFile(
require.resolve('../../apis/observability/alerting/burn_rate/consumers_and_privileges')
);
});
}

View file

@ -8,7 +8,7 @@
import { createStatefulFeatureFlagTestConfig } from '../../default_configs/feature_flag.stateful.config.base';
export default createStatefulFeatureFlagTestConfig({
testFiles: [require.resolve('./oblt.synthetics.index.ts')],
testFiles: [require.resolve('./oblt.index.ts')],
kbnServerArgs: ['--xpack.actions.preconfigured'],
junit: {
reportName: 'Stateful Observability - Deployment-agnostic Feature Flag API Integration Tests',

View file

@ -263,6 +263,7 @@ export function AlertingApiProvider({ getService }: DeploymentAgnosticFtrProvide
consumer,
notifyWhen,
enabled = true,
expectedStatusCode = 200,
}: {
roleAuthc: RoleCredentials;
ruleTypeId: string;
@ -274,6 +275,7 @@ export function AlertingApiProvider({ getService }: DeploymentAgnosticFtrProvide
schedule?: { interval: string };
notifyWhen?: string;
enabled?: boolean;
expectedStatusCode?: number;
}) {
const { body } = await supertestWithoutAuth
.post(`/api/alerting/rule`)
@ -292,7 +294,7 @@ export function AlertingApiProvider({ getService }: DeploymentAgnosticFtrProvide
actions,
...(notifyWhen ? { notify_when: notifyWhen, throttle: '5m' } : {}),
})
.expect(200);
.expect(expectedStatusCode);
return body;
},
@ -926,13 +928,15 @@ export function AlertingApiProvider({ getService }: DeploymentAgnosticFtrProvide
expectedStatus,
roleAuthc,
spaceId,
timeout = retryTimeout,
}: {
ruleId: string;
expectedStatus: string;
roleAuthc: RoleCredentials;
spaceId?: string;
timeout?: number;
}) {
return await retry.tryForTime(retryTimeout, async () => {
return await retry.tryForTime(timeout, async () => {
const response = await supertestWithoutAuth
.get(`${spaceId ? '/s/' + spaceId : ''}/api/alerting/rule/${ruleId}`)
.set(roleAuthc.apiKeyHeader)

View file

@ -8,12 +8,17 @@
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
const RULE_ALERT_INDEX_PATTERN = '.alerts-stack.alerts-*';
export default ({ getService, getPageObjects }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const testSubjects = getService('testSubjects');
const supertest = getService('supertest');
const find = getService('find');
const retry = getService('retry');
const rulesService = getService('rules');
const esClient = getService('es');
const kibanaServer = getService('kibanaServer');
const RULE_ENDPOINT = '/api/alerting/rule';
const INTERNAL_RULE_ENDPOINT = '/internal/alerting/rules';
@ -111,11 +116,23 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts');
await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs');
await observability.alerts.common.navigateWithoutFilter();
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX_PATTERN,
query: { match_all: {} },
conflicts: 'proceed',
});
await kibanaServer.savedObjects.cleanStandardList();
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts');
await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs');
await esClient.deleteByQuery({
index: RULE_ALERT_INDEX_PATTERN,
query: { match_all: {} },
conflicts: 'proceed',
});
await kibanaServer.savedObjects.cleanStandardList();
});
describe('Feature flag', () => {
@ -382,5 +399,30 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
});
});
});
describe('Stack alerts consumer', () => {
it('should create an ES Query rule and NOT display it when consumer is stackAlerts', async () => {
const name = 'ES Query with stackAlerts consumer';
await rulesService.api.createRule({
name,
consumer: 'stackAlerts',
ruleTypeId: '.es-query',
params: {
size: 100,
thresholdComparator: '>',
threshold: [-1],
index: ['alert-test-data'],
timeField: 'date',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 20,
timeWindowUnit: 's',
},
schedule: { interval: '1m' },
});
await observability.alerts.common.navigateToRulesPage();
await testSubjects.missingOrFail('rule-row');
});
});
});
};

View file

@ -28,5 +28,5 @@ export default createTestConfig({
// include settings from project controller
// https://github.com/elastic/project-controller/blob/main/internal/project/observability/config/elasticsearch.yml
esServerArgs: ['xpack.ml.dfa.enabled=false'],
esServerArgs: ['xpack.ml.dfa.enabled=false', 'xpack.security.authc.native_roles.enabled=true'],
});

View file

@ -11,6 +11,8 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('serverless observability UI - feature flags', function () {
// add tests that require feature flags, defined in config.feature_flags.ts
loadTestFile(require.resolve('./role_management'));
loadTestFile(require.resolve('./rules/custom_threshold_consumer'));
loadTestFile(require.resolve('./rules/es_query_consumer'));
loadTestFile(require.resolve('./infra'));
loadTestFile(require.resolve('./streams'));
});

View file

@ -19,6 +19,9 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./discover/embeddables'));
loadTestFile(require.resolve('./onboarding'));
loadTestFile(require.resolve('./rules/rules_list'));
// moved to feature flags config until custom roles in serverless are supported
// loadTestFile(require.resolve('./rules/custom_threshold_consumer'));
// loadTestFile(require.resolve('./rules/es_query_consumer'));
loadTestFile(require.resolve('./cases'));
loadTestFile(require.resolve('./advanced_settings'));
loadTestFile(require.resolve('./ml'));

View file

@ -0,0 +1,229 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { expect } from 'expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { RoleCredentials } from '../../../../shared/services';
export default ({ getPageObject, getService }: FtrProviderContext) => {
const svlCommonPage = getPageObject('svlCommonPage');
const svlCommonNavigation = getPageObject('svlCommonNavigation');
const testSubjects = getService('testSubjects');
const supertest = getService('supertest');
const retry = getService('retry');
const svlUserManager = getService('svlUserManager');
const alertingApi = getService('alertingApi');
const dataViewApi = getService('dataViewApi');
const kibanaServer = getService('kibanaServer');
const samlAuth = getService('samlAuth');
let roleAuthc: RoleCredentials;
function createCustomThresholdRule({ ruleName }: { ruleName: string }) {
it('navigates to the rules page', async () => {
await svlCommonNavigation.sidenav.clickLink({ text: 'Alerts' });
await testSubjects.click('manageRulesPageButton');
});
it('should open the rule creation flyout', async () => {
await retry.try(async () => {
await testSubjects.click('createRuleButton');
const isCreateRuleFlyoutVisible = await testSubjects.exists('ruleTypeModal');
expect(isCreateRuleFlyoutVisible).toBe(true);
});
});
it('should click the custom threshold rule type', async () => {
await testSubjects.click('observability.rules.custom_threshold-SelectOption');
const ruleType = await testSubjects.getVisibleText('ruleDefinitionHeaderRuleTypeName');
expect(ruleType).toEqual('Custom threshold');
await testSubjects.exists('selectDataViewExpression');
});
it('should create a new custom threshold rule', async () => {
const input = await testSubjects.find('ruleDetailsNameInput');
await input.clearValueWithKeyboard();
await testSubjects.setValue('ruleDetailsNameInput', ruleName);
await retry.try(async () => {
await testSubjects.click('rulePageFooterSaveButton');
const doesConfirmModalExist = await testSubjects.exists('confirmModalConfirmButton');
expect(doesConfirmModalExist).toBe(true);
await testSubjects.click('confirmModalConfirmButton');
const name = await testSubjects.getVisibleText('ruleName');
expect(name).toEqual(ruleName);
});
});
}
describe('Custom threshold rule - consumers', function () {
// custom roles are not yet supported in MKI
this.tags(['skipMKI']);
const ruleIdList: string[] = [];
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
// re-create the default data view in case it has been cleaned up by another test
await dataViewApi.create({
id: 'default_all_data_id',
name: 'default:all-data',
title: '*,-.*',
});
roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('admin');
});
afterEach(async () => {
await Promise.all(
ruleIdList.map(async (ruleId) => {
await supertest
.delete(`/api/alerting/rule/${ruleId}`)
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo');
})
);
});
after(async () => {
await svlUserManager.invalidateM2mApiKeyWithRoleScope(roleAuthc);
await kibanaServer.savedObjects.cleanStandardList();
});
describe('both logs and infrastructure privileges', () => {
const uuid = ` ${uuidv4()}`;
const ruleName = `Custom threshold rule${uuid}`;
it('logs in with privileged role', async () => {
await svlCommonPage.loginWithPrivilegedRole();
});
createCustomThresholdRule({ ruleName });
it('should have logs consumer by default', async () => {
const searchResults = (await alertingApi.searchRules(
roleAuthc,
`alert.attributes.name:"${ruleName}"`
)) as { body: { data: Array<{ consumer: string; id: string }> } };
const rule = searchResults.body.data[0];
expect(rule.consumer).toEqual('logs');
ruleIdList.push(rule.id);
});
});
describe('only logs privileges', () => {
const uuid = ` ${uuidv4()}`;
const ruleName = `Custom threshold rule${uuid}`;
it('logs in with logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
await svlCommonPage.loginWithCustomRole();
});
createCustomThresholdRule({ ruleName });
it('should have logs consumer by default', async () => {
const searchResults = await alertingApi.searchRules(
roleAuthc,
`alert.attributes.name:"${ruleName}"`
);
const rule = searchResults.body.data[0];
expect(rule.consumer).toEqual('logs');
ruleIdList.push(rule.id);
});
});
describe('only infrastructure privileges', () => {
const uuid = ` ${uuidv4()}`;
const ruleName = `Custom threshold rule${uuid}`;
it('logs in with infra only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
await svlCommonPage.loginWithCustomRole();
});
createCustomThresholdRule({ ruleName });
it('should have infrastructure consumer by default', async () => {
const searchResults = await alertingApi.searchRules(
roleAuthc,
`alert.attributes.name:"${ruleName}"`
);
const rule = searchResults.body.data[0];
expect(rule.consumer).toEqual('infrastructure');
ruleIdList.push(rule.id);
});
});
});
};
export const ROLES = {
infra_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
infrastructure: ['all'],
indexPatterns: ['all'],
},
},
],
},
logs_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
logs: ['all'],
indexPatterns: ['all'],
},
},
],
},
synthetics_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
uptime: ['all'],
},
},
],
},
};

View file

@ -0,0 +1,227 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { expect } from 'expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { RoleCredentials } from '../../../../shared/services';
export default ({ getPageObject, getService }: FtrProviderContext) => {
const svlCommonPage = getPageObject('svlCommonPage');
const svlCommonNavigation = getPageObject('svlCommonNavigation');
const testSubjects = getService('testSubjects');
const supertest = getService('supertest');
const retry = getService('retry');
const svlUserManager = getService('svlUserManager');
const alertingApi = getService('alertingApi');
const dataViewApi = getService('dataViewApi');
const samlAuth = getService('samlAuth');
const kibanaServer = getService('kibanaServer');
let roleAuthc: RoleCredentials;
function createESQueryRule({ ruleName }: { ruleName: string }) {
it('navigates to the rules page', async () => {
await svlCommonNavigation.sidenav.clickLink({ text: 'Alerts' });
await testSubjects.click('manageRulesPageButton');
});
it('should open the rule creation flyout', async () => {
await testSubjects.click('createRuleButton');
const isCreateRuleFlyoutVisible = await testSubjects.exists('ruleTypeModal');
expect(isCreateRuleFlyoutVisible).toBe(true);
});
it('should click the es query rule type', async () => {
await testSubjects.click('.es-query-SelectOption');
const ruleType = await testSubjects.getVisibleText('ruleDefinitionHeaderRuleTypeName');
expect(ruleType).toEqual('Elasticsearch query');
});
it('should create a new es query rule', async () => {
await testSubjects.click('queryFormType_searchSource');
await testSubjects.exists('selectDataViewExpression');
const input = await testSubjects.find('ruleDetailsNameInput');
await input.clearValueWithKeyboard();
await testSubjects.setValue('ruleDetailsNameInput', ruleName);
await retry.try(async () => {
await testSubjects.click('rulePageFooterSaveButton');
const doesConfirmModalExist = await testSubjects.exists('confirmModalConfirmButton');
expect(doesConfirmModalExist).toBe(true);
});
await testSubjects.click('confirmModalConfirmButton');
const name = await testSubjects.getVisibleText('ruleName');
expect(name).toEqual(ruleName);
});
}
describe('ES Query rule - consumers', function () {
// custom roles are not yet supported in MKI
this.tags(['skipMKI']);
const ruleIdList: string[] = [];
before(async () => {
roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('admin');
await kibanaServer.savedObjects.cleanStandardList();
// re-create the default data view in case it has been cleaned up by another test
await dataViewApi.create({
id: 'default_all_data_id',
name: 'default:all-data',
title: '*,-.*',
});
});
afterEach(async () => {
await Promise.all(
ruleIdList.map(async (ruleId) => {
await supertest
.delete(`/api/alerting/rule/${ruleId}`)
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo');
})
);
});
after(async () => {
await svlUserManager.invalidateM2mApiKeyWithRoleScope(roleAuthc);
});
describe('both logs and infrastructure privileges', () => {
const uuid = ` ${uuidv4()}`;
const ruleName = `ES Query rule${uuid}`;
it('logs in with privileged role', async () => {
await svlCommonPage.loginWithPrivilegedRole();
});
createESQueryRule({ ruleName });
it('should have logs consumer by default', async () => {
const searchResults = await alertingApi.searchRules(
roleAuthc,
`alert.attributes.name:"${ruleName}"`
);
const rule = searchResults.body.data[0];
expect(rule.consumer).toEqual('logs');
ruleIdList.push(rule.id);
});
});
describe('only logs privileges', () => {
const uuid = ` ${uuidv4()}`;
const ruleName = `ES Query rule${uuid}`;
it('logs in with logs only role', async () => {
await samlAuth.setCustomRole(ROLES.logs_only);
await svlCommonPage.loginWithCustomRole();
});
createESQueryRule({ ruleName });
it('should have logs consumer by default', async () => {
const searchResults = await alertingApi.searchRules(
roleAuthc,
`alert.attributes.name:"${ruleName}"`
);
const rule = searchResults.body.data[0];
expect(rule.consumer).toEqual('logs');
ruleIdList.push(rule.id);
});
});
describe('only infrastructure privileges', () => {
const uuid = ` ${uuidv4()}`;
const ruleName = `ES Query rule${uuid}`;
it('logs in with infra only role', async () => {
await samlAuth.setCustomRole(ROLES.infra_only);
await svlCommonPage.loginWithCustomRole();
});
createESQueryRule({ ruleName });
it('should have infrastructure consumer by default', async () => {
const searchResults = await alertingApi.searchRules(
roleAuthc,
`alert.attributes.name:"${ruleName}"`
);
const rule = searchResults.body.data[0];
expect(rule.consumer).toEqual('infrastructure');
ruleIdList.push(rule.id);
});
});
});
};
export const ROLES = {
infra_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
indexPatterns: ['all'],
infrastructure: ['all'],
},
},
],
},
logs_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
indexPatterns: ['all'],
logs: ['all'],
},
},
],
},
synthetics_only: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['read'],
},
],
},
kibana: [
{
base: [],
spaces: ['*'],
feature: {
actions: ['all'],
maintenanceWindow: ['all'],
observabilityCasesV3: ['all'],
uptime: ['all'],
},
},
],
},
};

View file

@ -23,6 +23,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
const log = getService('log');
const svlUserManager = getService('svlUserManager');
const alertingApi = getService('alertingApi');
const kibanaServer = getService('kibanaServer');
let roleAuthc: RoleCredentials;
async function refreshRulesList() {
@ -74,6 +75,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
};
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('admin');
await svlCommonPage.loginWithPrivilegedRole();
await svlObltNavigation.navigateToLandingPage();
@ -90,6 +92,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
.set('x-elastic-internal-origin', 'foo');
})
);
await kibanaServer.savedObjects.cleanStandardList();
});
after(async () => {
@ -99,7 +102,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
it('should create an ES Query Rule and display it when consumer is observability', async () => {
const esQuery = await alertingApi.helpers.createEsQueryRule({
roleAuthc,
name: 'ES Query',
name: 'ES Query with observability consumer',
consumer: 'observability',
ruleTypeId: '.es-query',
params: {
@ -118,13 +121,15 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await refreshRulesList();
const searchResults = await svlTriggersActionsUI.getRulesList();
expect(searchResults.length).toEqual(1);
expect(searchResults[0].name).toEqual('ES QueryElasticsearch query');
expect(searchResults[0].name).toEqual(
'ES Query with observability consumerElasticsearch query'
);
});
it('should create an ES Query rule but not display it when consumer is stackAlerts', async () => {
it('should create an ES Query rule and NOT display it when consumer is stackAlerts', async () => {
const esQuery = await alertingApi.helpers.createEsQueryRule({
roleAuthc,
name: 'ES Query',
name: 'ES Query with stackAlerts consumer',
consumer: 'stackAlerts',
ruleTypeId: '.es-query',
params: {
@ -684,9 +689,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await testSubjects.click('ruleTypeFilterButton');
}
expect(await (await testSubjects.find('ruleType0Group')).getVisibleText()).toEqual(
'Observability'
);
expect(await (await testSubjects.find('ruleType0Group')).getVisibleText()).toEqual('Apm');
});
await testSubjects.click('ruleTypeapm.anomalyFilterOption');