[Cases] Update cases ids in the alerts schema when attaching an alert to a case (#147985)

## Summary

PR https://github.com/elastic/kibana/pull/147013 extended the alert's
schema to be able to store the case id an alert is attached to. This PR
adds the ability to update the `case_ids` field of the alert when an
alert is attached to a case. It also limits the number of cases an alert
can be attached to ten.

### Permissions

A user to attach an alert to a case needs a) to have read access to the
solution via the kibana feature privileges and b) to have write access
to the case. The user that did the request should not need to have
written access to the alert for Cases to add the case ID to the alert's
data. For that reason, the alert data client is extended to cover this
particular need: update an alert even if the user has read access.

## Alerts client aside

For future reference, the alerts client uses the request to check the
kibana feature authorization but uses the internal system user to
perform any write and get operations on the alert indices themselves.
For security solution this means that a user with only read access to
the security solution kibana feature, write access to cases, and no read
or write access to the alert indices could attach an alert to a case and
have the case id stored in the alert.

For observability, users intentionally do not have access to the alert
indices so we want to bypass the authorization check on the indices
which is why the current alerts client uses an es client that is an
internal system user.

Related issue: https://github.com/elastic/kibana/issues/146864

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Christos Nasikas 2023-02-11 12:11:30 +02:00 committed by GitHub
parent 4f95b7c7ed
commit 1f19c9e105
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1450 additions and 227 deletions

View file

@ -11,4 +11,5 @@ export * from './src/technical_field_names';
export * from './src/alerts_as_data_rbac';
export * from './src/alerts_as_data_severity';
export * from './src/alerts_as_data_status';
export * from './src/alerts_as_data_cases';
export * from './src/routes/stack_rule_paths';

View file

@ -0,0 +1,9 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export const MAX_CASES_PER_ALERT = 10;

View file

@ -24,7 +24,8 @@
"triggersActionsUi",
"management",
"security",
"notifications"
"notifications",
"ruleRegistry"
],
"optionalPlugins": [
"home",

View file

@ -6,6 +6,7 @@
*/
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { alertsClientMock } from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client.mock';
import { AlertService } from '../../services';
import type { CasesClientArgs } from '../types';
import { getAlerts } from './get';
@ -13,10 +14,11 @@ import { getAlerts } from './get';
describe('getAlerts', () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient();
const logger = loggingSystemMock.create().get('case');
const alertsClient = alertsClientMock.create();
let alertsService: AlertService;
beforeEach(async () => {
alertsService = new AlertService(esClient, logger);
alertsService = new AlertService(esClient, logger, alertsClient);
jest.clearAllMocks();
});

View file

@ -24,16 +24,21 @@ export type CasesClientGetAlertsResponse = Alert[];
/**
* Defines the fields necessary to update an alert's status.
*/
export interface UpdateAlertRequest {
export interface UpdateAlertStatusRequest {
id: string;
index: string;
status: CaseStatuses;
}
export interface AlertUpdateStatus {
alerts: UpdateAlertRequest[];
alerts: UpdateAlertStatusRequest[];
}
export interface AlertGet {
alertsInfo: AlertInfo[];
}
export interface UpdateAlertCasesRequest {
alerts: AlertInfo[];
caseIds: string[];
}

View file

@ -31,7 +31,7 @@ import { CASE_COMMENT_SAVED_OBJECT } from '../../../common/constants';
import { createIncident, getDurationInSeconds, getUserProfiles } from './utils';
import { createCaseError } from '../../common/error';
import {
createAlertUpdateRequest,
createAlertUpdateStatusRequest,
flattenCaseSavedObject,
getAlertInfoFromComments,
} from '../../common/utils';
@ -68,7 +68,7 @@ const changeAlertsStatusToClose = async (
const alerts = alertAttachments.saved_objects
.map((attachment) =>
createAlertUpdateRequest({
createAlertUpdateStatusRequest({
comment: attachment.attributes,
status: CaseStatuses.closed,
})

View file

@ -51,11 +51,11 @@ import { arraysDifference, getCaseToUpdate } from '../utils';
import type { AlertService, CasesService } from '../../services';
import { createCaseError } from '../../common/error';
import {
createAlertUpdateRequest,
createAlertUpdateStatusRequest,
flattenCaseSavedObject,
isCommentRequestTypeAlert,
} from '../../common/utils';
import type { UpdateAlertRequest } from '../alerts/types';
import type { UpdateAlertStatusRequest } from '../alerts/types';
import type { CasesClientArgs } from '..';
import type { OwnerEntity } from '../../authorization';
import { Operations } from '../../authorization';
@ -238,14 +238,14 @@ async function updateAlerts({
// create an array of requests that indicate the id, index, and status to update an alert
const alertsToUpdate = totalAlerts.saved_objects.reduce(
(acc: UpdateAlertRequest[], alertComment) => {
(acc: UpdateAlertStatusRequest[], alertComment) => {
if (isCommentRequestTypeAlert(alertComment.attributes)) {
const status = getSyncStatusForComment({
alertComment,
casesToSyncToStatus,
});
acc.push(...createAlertUpdateRequest({ comment: alertComment.attributes, status }));
acc.push(...createAlertUpdateStatusRequest({ comment: alertComment.attributes, status }));
}
return acc;

View file

@ -26,6 +26,12 @@ import type { LensServerPluginSetup } from '@kbn/lens-plugin/server';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/server';
import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server';
import type {
AlertsClient,
RuleRegistryPluginStartContract,
} from '@kbn/rule-registry-plugin/server';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { SAVED_OBJECT_TYPES } from '../../common/constants';
import { Authorization } from '../authorization/authorization';
@ -55,10 +61,11 @@ interface CasesClientFactoryArgs {
actionsPluginStart: ActionsPluginStart;
licensingPluginStart: LicensingPluginStart;
lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory'];
notifications: NotificationsPluginStart;
ruleRegistry: RuleRegistryPluginStartContract;
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
publicBaseUrl?: IBasePath['publicBaseUrl'];
notifications: NotificationsPluginStart;
}
/**
@ -122,6 +129,7 @@ export class CasesClientFactory {
});
const savedObjectsSerializer = savedObjectsService.createSerializer();
const alertsClient = await this.options.ruleRegistry.getRacClientWithRequest(request);
const services = this.createServices({
unsecuredSavedObjectsClient,
@ -129,6 +137,7 @@ export class CasesClientFactory {
esClient: scopedClusterClient,
request,
auditLogger,
alertsClient,
});
const userInfo = await this.getUserInfo(request);
@ -163,12 +172,14 @@ export class CasesClientFactory {
esClient,
request,
auditLogger,
alertsClient,
}: {
unsecuredSavedObjectsClient: SavedObjectsClientContract;
savedObjectsSerializer: ISavedObjectsSerializer;
esClient: ElasticsearchClient;
request: KibanaRequest;
auditLogger: AuditLogger;
alertsClient: PublicMethodsOf<AlertsClient>;
}): CasesServices {
this.validateInitialization();
@ -204,7 +215,7 @@ export class CasesClientFactory {
});
return {
alertsService: new AlertService(esClient, this.logger),
alertsService: new AlertService(esClient, this.logger, alertsClient),
caseService,
caseConfigureService: new CaseConfigureService(this.logger),
connectorMappingsService: new ConnectorMappingsService(this.logger),

View file

@ -35,17 +35,18 @@ import {
import type { CasesClientArgs } from '../../client';
import type { RefreshSetting } from '../../services/types';
import { createCaseError } from '../error';
import type { CaseSavedObject } from '../types';
import type { AlertInfo, CaseSavedObject } from '../types';
import {
countAlertsForID,
flattenCommentSavedObjects,
transformNewComment,
getOrUpdateLensReferences,
createAlertUpdateRequest,
isCommentRequestTypeAlert,
getAlertInfoFromComments,
} from '../utils';
type CaseCommentModelParams = Omit<CasesClientArgs, 'authorization'>;
const ALERT_LIMIT_MSG = `Case has reached the maximum allowed number (${MAX_ALERTS_PER_CASE}) of attached alerts.`;
/**
@ -306,27 +307,38 @@ export class CaseCommentModel {
}
private async handleAlertComments(attachments: CommentRequest[]) {
const alerts = attachments.filter(
(attachment) =>
attachment.type === CommentType.alert && this.caseInfo.attributes.settings.syncAlerts
const alertAttachments = attachments.filter(
(attachment): attachment is CommentRequestAlertType => attachment.type === CommentType.alert
);
await this.updateAlertsStatus(alerts);
const alerts = getAlertInfoFromComments(alertAttachments);
if (alerts.length > 0) {
await this.params.services.alertsService.ensureAlertsAuthorized({ alerts });
await this.updateAlertsSchemaWithCaseInfo(alerts);
if (this.caseInfo.attributes.settings.syncAlerts) {
await this.updateAlertsStatus(alerts);
}
}
}
private async updateAlertsStatus(alerts: CommentRequest[]) {
const alertsToUpdate = alerts
.map((alert) =>
createAlertUpdateRequest({
comment: alert,
status: this.caseInfo.attributes.status,
})
)
.flat();
private async updateAlertsStatus(alerts: AlertInfo[]) {
const alertsToUpdate = alerts.map((alert) => ({
...alert,
status: this.caseInfo.attributes.status,
}));
await this.params.services.alertsService.updateAlertsStatus(alertsToUpdate);
}
private async updateAlertsSchemaWithCaseInfo(alerts: AlertInfo[]) {
await this.params.services.alertsService.bulkUpdateCases({
alerts,
caseIds: [this.caseInfo.id],
});
}
private async createCommentUserAction(
comment: SavedObject<CommentAttributes>,
req: CommentRequest

View file

@ -48,7 +48,7 @@ import {
ConnectorTypes,
ExternalReferenceStorageType,
} from '../../common/api';
import type { UpdateAlertRequest } from '../client/alerts/types';
import type { UpdateAlertStatusRequest } from '../client/alerts/types';
import {
parseCommentString,
getLensVisualizations,
@ -267,13 +267,13 @@ export const isCommentRequestTypeExternalReferenceSO = (
/**
* Adds the ids and indices to a map of statuses
*/
export function createAlertUpdateRequest({
export function createAlertUpdateStatusRequest({
comment,
status,
}: {
comment: CommentRequest;
status: CaseStatuses;
}): UpdateAlertRequest[] {
}): UpdateAlertStatusRequest[] {
return getAlertInfoFromComments([comment]).map((alert) => ({ ...alert, status }));
}

View file

@ -32,6 +32,7 @@ import type {
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/server';
import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server';
import type { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/server';
import { APP_ID } from '../common/constants';
import {
@ -74,6 +75,7 @@ export interface PluginsStart {
security: SecurityPluginStart;
spaces?: SpacesPluginStart;
notifications: NotificationsPluginStart;
ruleRegistry: RuleRegistryPluginStartContract;
}
export class CasePlugin {
@ -202,6 +204,7 @@ export class CasePlugin {
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
publicBaseUrl: core.http.basePath.publicBaseUrl,
notifications: plugins.notifications,
ruleRegistry: plugins.ruleRegistry,
});
const client = core.elasticsearch.client;

View file

@ -5,17 +5,19 @@
* 2.0.
*/
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { alertsClientMock } from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client.mock';
import { CaseStatuses } from '../../../common/api';
import { AlertService } from '.';
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
describe('updateAlertsStatus', () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient();
const logger = loggingSystemMock.create().get('case');
const alertsClient = alertsClientMock.create();
let alertService: AlertService;
beforeEach(async () => {
alertService = new AlertService(esClient, logger);
alertService = new AlertService(esClient, logger, alertsClient);
jest.clearAllMocks();
});
@ -336,4 +338,47 @@ describe('updateAlertsStatus', () => {
expect(res).toBe(undefined);
});
});
describe('bulkUpdateCases', () => {
const alerts = [
{
id: 'c3869d546717e8c581add9cbf7d24578f34cd3e72cbc8d8b8e9a9330a899f70f',
index: '.internal.alerts-security.alerts-default-000001',
},
];
const caseIds = ['test-case'];
it('update case info', async () => {
await alertService.bulkUpdateCases({ alerts, caseIds });
expect(alertsClient.bulkUpdateCases).toBeCalledWith({ alerts, caseIds });
});
it('filters out alerts with empty id', async () => {
await alertService.bulkUpdateCases({
alerts: [{ id: '', index: 'test-index' }, ...alerts],
caseIds,
});
expect(alertsClient.bulkUpdateCases).toBeCalledWith({ alerts, caseIds });
});
it('filters out alerts with empty index', async () => {
await alertService.bulkUpdateCases({
alerts: [{ id: 'test-id', index: '' }, ...alerts],
caseIds,
});
expect(alertsClient.bulkUpdateCases).toBeCalledWith({ alerts, caseIds });
});
it('does not call the alerts client with no alerts', async () => {
await alertService.bulkUpdateCases({
alerts: [{ id: '', index: 'test-index' }],
caseIds,
});
expect(alertsClient.bulkUpdateCases).not.toHaveBeenCalled();
});
});
});

View file

@ -12,17 +12,20 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import type { STATUS_VALUES } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
import type { MgetResponse } from '@elastic/elasticsearch/lib/api/types';
import type { AlertsClient } from '@kbn/rule-registry-plugin/server';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { CaseStatuses } from '../../../common/api';
import { MAX_ALERTS_PER_CASE, MAX_CONCURRENT_SEARCHES } from '../../../common/constants';
import { createCaseError } from '../../common/error';
import type { AlertInfo } from '../../common/types';
import type { UpdateAlertRequest } from '../../client/alerts/types';
import type { UpdateAlertCasesRequest, UpdateAlertStatusRequest } from '../../client/alerts/types';
import type { AggregationBuilder, AggregationResponse } from '../../client/metrics/types';
export class AlertService {
constructor(
private readonly scopedClusterClient: ElasticsearchClient,
private readonly logger: Logger
private readonly logger: Logger,
private readonly alertsClient: PublicMethodsOf<AlertsClient>
) {}
public async executeAggregations({
@ -75,7 +78,7 @@ export class AlertService {
};
}
public async updateAlertsStatus(alerts: UpdateAlertRequest[]) {
public async updateAlertsStatus(alerts: UpdateAlertStatusRequest[]) {
try {
const bucketedAlerts = this.bucketAlertsByIndexAndStatus(alerts);
const indexBuckets = Array.from(bucketedAlerts.entries());
@ -96,7 +99,7 @@ export class AlertService {
}
private bucketAlertsByIndexAndStatus(
alerts: UpdateAlertRequest[]
alerts: UpdateAlertStatusRequest[]
): Map<string, Map<STATUS_VALUES, TranslatedUpdateAlertRequest[]>> {
return alerts.reduce<Map<string, Map<STATUS_VALUES, TranslatedUpdateAlertRequest[]>>>(
(acc, alert) => {
@ -130,7 +133,7 @@ export class AlertService {
return isEmpty(alert.id) || isEmpty(alert.index);
}
private translateStatus(alert: UpdateAlertRequest): STATUS_VALUES {
private translateStatus(alert: UpdateAlertStatusRequest): STATUS_VALUES {
const translatedStatuses: Record<string, STATUS_VALUES> = {
[CaseStatuses.open]: 'open',
[CaseStatuses['in-progress']]: 'acknowledged',
@ -179,6 +182,10 @@ export class AlertService {
);
}
private getNonEmptyAlerts(alerts: AlertInfo[]): AlertInfo[] {
return alerts.filter((alert) => !AlertService.isEmptyAlert(alert));
}
public async getAlerts(alertsInfo: AlertInfo[]): Promise<MgetResponse<Alert> | undefined> {
try {
const docs = alertsInfo
@ -201,6 +208,47 @@ export class AlertService {
});
}
}
public async bulkUpdateCases({ alerts, caseIds }: UpdateAlertCasesRequest): Promise<void> {
try {
const nonEmptyAlerts = this.getNonEmptyAlerts(alerts);
if (nonEmptyAlerts.length <= 0) {
return;
}
await this.alertsClient.bulkUpdateCases({
alerts: nonEmptyAlerts,
caseIds,
});
} catch (error) {
throw createCaseError({
message: `Failed to add case info to alerts for caseIds ${caseIds}: ${error}`,
error,
logger: this.logger,
});
}
}
public async ensureAlertsAuthorized({ alerts }: { alerts: AlertInfo[] }): Promise<void> {
try {
const nonEmptyAlerts = this.getNonEmptyAlerts(alerts);
if (nonEmptyAlerts.length <= 0) {
return;
}
await this.alertsClient.ensureAllAlertsAuthorizedRead({
alerts: nonEmptyAlerts,
});
} catch (error) {
throw createCaseError({
message: `Failed to authorize alerts: ${error}`,
error,
logger: this.logger,
});
}
}
}
interface TranslatedUpdateAlertRequest {

View file

@ -137,6 +137,8 @@ export const createAlertServiceMock = (): AlertServiceMock => {
updateAlertsStatus: jest.fn(),
getAlerts: jest.fn(),
executeAggregations: jest.fn(),
bulkUpdateCases: jest.fn(),
ensureAlertsAuthorized: jest.fn(),
};
// the cast here is required because jest.Mocked tries to include private members and would throw an error

View file

@ -17,10 +17,12 @@ const createAlertsClientMock = () => {
update: jest.fn(),
getAuthorizedAlertsIndices: jest.fn(),
bulkUpdate: jest.fn(),
bulkUpdateCases: jest.fn(),
find: jest.fn(),
getFeatureIdsByRegistrationContexts: jest.fn(),
getBrowserFields: jest.fn(),
getAlertSummary: jest.fn(),
ensureAllAlertsAuthorizedRead: jest.fn(),
};
return mocked;
};

View file

@ -21,6 +21,8 @@ import {
ALERT_STATUS_RECOVERED,
ALERT_END,
ALERT_STATUS_ACTIVE,
ALERT_CASE_IDS,
MAX_CASES_PER_ALERT,
} from '@kbn/rule-data-utils';
import {
@ -93,6 +95,16 @@ export interface BulkUpdateOptions<Params extends RuleTypeParams> {
query: object | string | undefined | null;
}
interface MgetAndAuditAlert {
id: string;
index: string;
}
export interface BulkUpdateCasesOptions {
alerts: MgetAndAuditAlert[];
caseIds: string[];
}
interface GetAlertParams {
id: string;
index?: string;
@ -161,6 +173,20 @@ export class AlertsClient {
: { [ALERT_WORKFLOW_STATUS]: status };
}
private getAlertCaseIdsFieldUpdate(source: ParsedTechnicalFields | undefined, caseIds: string[]) {
const uniqueCaseIds = new Set([...(source?.[ALERT_CASE_IDS] ?? []), ...caseIds]);
return { [ALERT_CASE_IDS]: Array.from(uniqueCaseIds.values()) };
}
private validateTotalCasesPerAlert(source: ParsedTechnicalFields | undefined, caseIds: string[]) {
const currentCaseIds = source?.[ALERT_CASE_IDS] ?? [];
if (currentCaseIds.length + caseIds.length > MAX_CASES_PER_ALERT) {
throw Boom.badRequest(`You cannot attach more than ${MAX_CASES_PER_ALERT} cases to an alert`);
}
}
/**
* Accepts an array of ES documents and executes ensureAuthorized for the given operation
* @param items
@ -322,40 +348,28 @@ export class AlertsClient {
* @returns
*/
private async mgetAlertsAuditOperate({
ids,
status,
indexName,
alerts,
operation,
fieldToUpdate,
validate,
}: {
ids: string[];
status: STATUS_VALUES;
indexName: string;
alerts: MgetAndAuditAlert[];
operation: ReadOperations.Find | ReadOperations.Get | WriteOperations.Update;
fieldToUpdate: (source: ParsedTechnicalFields | undefined) => Record<string, unknown>;
validate?: (source: ParsedTechnicalFields | undefined) => void;
}) {
try {
const mgetRes = await this.esClient.mget<ParsedTechnicalFields>({
index: indexName,
body: {
ids,
},
});
const mgetRes = await this.ensureAllAlertsAuthorized({ alerts, operation });
await this.ensureAllAuthorized(mgetRes.docs, operation);
const updateRequests = [];
for (const id of ids) {
this.auditLogger?.log(
alertAuditEvent({
action: operationAlertAuditActionMap[operation],
id,
...this.getOutcome(operation),
})
);
}
for (const item of mgetRes.docs) {
if (validate) {
// @ts-expect-error doesn't handle error branch in MGetResponse
validate(item?._source);
}
const bulkUpdateRequest = mgetRes.docs.flatMap((item) => {
// @ts-expect-error doesn't handle error branch in MGetResponse
const fieldToUpdate = this.getAlertStatusFieldUpdate(item?._source, status);
return [
updateRequests.push([
{
update: {
_index: item._index,
@ -364,11 +378,14 @@ export class AlertsClient {
},
{
doc: {
...fieldToUpdate,
// @ts-expect-error doesn't handle error branch in MGetResponse
...fieldToUpdate(item?._source),
},
},
];
});
]);
}
const bulkUpdateRequest = updateRequests.flat();
const bulkUpdateResponse = await this.esClient.bulk({
refresh: 'wait_for',
@ -381,6 +398,27 @@ export class AlertsClient {
}
}
/**
* When an update by ids is requested, do a multi-get, ensure authz and audit alerts, then execute bulk update
* @param param0
* @returns
*/
private async mgetAlertsAuditOperateStatus({
alerts,
status,
operation,
}: {
alerts: MgetAndAuditAlert[];
status: STATUS_VALUES;
operation: ReadOperations.Find | ReadOperations.Get | WriteOperations.Update;
}) {
return this.mgetAlertsAuditOperate({
alerts,
operation,
fieldToUpdate: (source) => this.getAlertStatusFieldUpdate(source, status),
});
}
private async buildEsQueryWithAuthz(
query: object | string | null | undefined,
id: string | null | undefined,
@ -492,6 +530,51 @@ export class AlertsClient {
}
}
/**
* Ensures that the user has access to the alerts
* for a given operation
*/
private async ensureAllAlertsAuthorized({
alerts,
operation,
}: {
alerts: MgetAndAuditAlert[];
operation: ReadOperations.Find | ReadOperations.Get | WriteOperations.Update;
}) {
try {
const mgetRes = await this.esClient.mget<ParsedTechnicalFields>({
docs: alerts.map(({ id, index }) => ({ _id: id, _index: index })),
});
await this.ensureAllAuthorized(mgetRes.docs, operation);
const ids = mgetRes.docs.map(({ _id }) => _id);
for (const id of ids) {
this.auditLogger?.log(
alertAuditEvent({
action: operationAlertAuditActionMap[operation],
id,
...this.getOutcome(operation),
})
);
}
return mgetRes;
} catch (exc) {
this.logger.error(`error in ensureAllAlertsAuthorized ${exc}`);
throw exc;
}
}
public async ensureAllAlertsAuthorizedRead({ alerts }: { alerts: MgetAndAuditAlert[] }) {
try {
await this.ensureAllAlertsAuthorized({ alerts, operation: ReadOperations.Get });
} catch (error) {
this.logger.error(`error authenticating alerts for read access: ${error}`);
throw error;
}
}
public async get({ id, index }: GetAlertParams) {
try {
// first search for the alert by id, then use the alert info to check if user has access to it
@ -676,10 +759,10 @@ export class AlertsClient {
}: BulkUpdateOptions<Params>) {
// rejects at the route level if more than 1000 id's are passed in
if (ids != null) {
return this.mgetAlertsAuditOperate({
ids,
const alerts = ids.map((id) => ({ id, index }));
return this.mgetAlertsAuditOperateStatus({
alerts,
status,
indexName: index,
operation: WriteOperations.Update,
});
} else if (query != null) {
@ -726,6 +809,43 @@ export class AlertsClient {
}
}
/**
* This function updates the case ids of multiple alerts per index.
* It is supposed to be used only by Cases.
* Cases implements its own RBAC. By using this function directly
* Cases RBAC is bypassed.
* Plugins that want to attach alerts to a case should use the
* cases client that does all the necessary cases RBAC checks
* before updating the alert with the case ids.
*/
public async bulkUpdateCases({ alerts, caseIds }: BulkUpdateCasesOptions) {
if (alerts.length === 0) {
throw Boom.badRequest('You need to define at least one alert to update case ids');
}
/**
* We do this check to avoid any mget calls or authorization checks.
* The check below does not ensure that an alert may exceed the limit.
* We need to also throw in case alert.caseIds + caseIds > MAX_CASES_PER_ALERT.
* The validateTotalCasesPerAlert function ensures that.
*/
if (caseIds.length > MAX_CASES_PER_ALERT) {
throw Boom.badRequest(`You cannot attach more than ${MAX_CASES_PER_ALERT} cases to an alert`);
}
return this.mgetAlertsAuditOperate({
alerts,
/**
* A user with read access to an alert and write access to a case should be able to link
* the case to the alert (update the alert's data to include the case ids).
* For that reason, the operation is a read operation.
*/
operation: ReadOperations.Get,
fieldToUpdate: (source) => this.getAlertCaseIdsFieldUpdate(source, caseIds),
validate: (source) => this.validateTotalCasesPerAlert(source, caseIds),
});
}
public async find<Params extends RuleTypeParams = never>({
query,
aggs,
@ -845,7 +965,7 @@ export class AlertsClient {
}
}
async getBrowserFields({
public async getBrowserFields({
indices,
metaFields,
allowNoIndex,

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type SuperTest from 'supertest';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ToolingLog } from '@kbn/tooling-log';
import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '@kbn/security-solution-plugin/common/constants';
import { DetectionAlert } from '@kbn/security-solution-plugin/common/detection_engine/schemas/alerts';
import { RiskEnrichmentFields } from '@kbn/security-solution-plugin/server/lib/detection_engine/signals/enrichments/types';
import {
getRuleForSignalTesting,
createRule,
waitForRuleSuccessOrStatus,
waitForSignalsToBePresent,
getSignalsByIds,
getQuerySignalIds,
} from '../../../detection_engine_api_integration/utils';
import { superUser } from './authentication/users';
import { User } from './authentication/types';
import { getSpaceUrlPrefix } from './api/helpers';
export const createSecuritySolutionAlerts = async (
supertest: SuperTest.SuperTest<SuperTest.Test>,
log: ToolingLog
): Promise<estypes.SearchResponse<DetectionAlert & RiskEnrichmentFields>> => {
const rule = getRuleForSignalTesting(['auditbeat-*']);
const { id } = await createRule(supertest, log, rule);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 1, [id]);
const signals = await getSignalsByIds(supertest, log, [id]);
return signals;
};
export const getSecuritySolutionAlerts = async (
supertest: SuperTest.SuperTest<SuperTest.Test>,
alertIds: string[]
): Promise<estypes.SearchResponse<DetectionAlert & RiskEnrichmentFields>> => {
const { body: updatedAlert } = await supertest
.post(DETECTION_ENGINE_QUERY_SIGNALS_URL)
.set('kbn-xsrf', 'true')
.send(getQuerySignalIds(alertIds))
.expect(200);
return updatedAlert;
};
interface AlertResponse {
'kibana.alert.case_ids'?: string[];
}
export const getAlertById = async ({
supertest,
id,
index,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
id: string;
index: string;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<AlertResponse> => {
const { body: alert } = await supertest
.get(`${getSpaceUrlPrefix(auth?.space)}/internal/rac/alerts?id=${id}&index=${index}`)
.auth(auth.user.username, auth.user.password)
.set('kbn-xsrf', 'true')
.expect(expectedHttpCode);
return alert;
};

View file

@ -190,6 +190,47 @@ export const securitySolutionOnlyRead: Role = {
},
};
export const securitySolutionOnlyReadAlerts: Role = {
name: 'sec_only_read_alerts',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
securitySolutionFixture: ['all'],
siem: ['read'],
},
spaces: ['space1'],
},
],
},
};
export const securitySolutionOnlyReadNoIndexAlerts: Role = {
name: 'sec_only_read_no_index_alerts',
privileges: {
elasticsearch: {
indices: [],
},
kibana: [
{
feature: {
securitySolutionFixture: ['all'],
siem: ['read'],
},
spaces: ['space1'],
},
],
},
};
export const observabilityOnlyAll: Role = {
name: 'obs_only_all',
privileges: {
@ -238,6 +279,25 @@ export const observabilityOnlyRead: Role = {
},
};
export const observabilityOnlyReadAlerts: Role = {
name: 'obs_only_read_alerts',
privileges: {
elasticsearch: {
indices: [],
},
kibana: [
{
feature: {
observabilityFixture: ['all'],
apm: ['read'],
logs: ['read'],
},
spaces: ['space1'],
},
],
},
};
/**
* These roles have access to all spaces.
*/
@ -272,9 +332,12 @@ export const roles = [
globalRead,
securitySolutionOnlyAll,
securitySolutionOnlyRead,
securitySolutionOnlyReadAlerts,
securitySolutionOnlyDelete,
securitySolutionOnlyNoDelete,
observabilityOnlyAll,
observabilityOnlyRead,
observabilityOnlyReadAlerts,
testDisabledPluginAll,
securitySolutionOnlyReadNoIndexAlerts,
];

View file

@ -17,6 +17,9 @@ import {
testDisabledPluginAll,
securitySolutionOnlyDelete,
securitySolutionOnlyNoDelete,
observabilityOnlyReadAlerts,
securitySolutionOnlyReadAlerts,
securitySolutionOnlyReadNoIndexAlerts,
} from './roles';
import { User } from './types';
@ -56,6 +59,18 @@ export const secOnlyRead: User = {
roles: [securitySolutionOnlyRead.name],
};
export const secOnlyReadAlerts: User = {
username: 'sec_only_read_alerts',
password: 'sec_only_read_alerts',
roles: [securitySolutionOnlyReadAlerts.name],
};
export const secSolutionOnlyReadNoIndexAlerts: User = {
username: 'sec_only_read_no_index_alerts',
password: 'sec_only_read_no_index_alerts',
roles: [securitySolutionOnlyReadNoIndexAlerts.name],
};
export const obsOnly: User = {
username: 'obs_only',
password: 'obs_only',
@ -68,6 +83,12 @@ export const obsOnlyRead: User = {
roles: [observabilityOnlyRead.name],
};
export const obsOnlyReadAlerts: User = {
username: 'obs_only_read_alerts',
password: 'obs_only_read_alerts',
roles: [observabilityOnlyReadAlerts.name],
};
export const obsSec: User = {
username: 'obs_sec',
password: 'obs_sec',
@ -112,10 +133,13 @@ export const users = [
superUser,
secOnly,
secOnlyRead,
secOnlyReadAlerts,
secSolutionOnlyReadNoIndexAlerts,
secOnlyDelete,
secOnlyNoDelete,
obsOnly,
obsOnlyRead,
obsOnlyReadAlerts,
obsSec,
obsSecRead,
globalRead,

View file

@ -7,9 +7,8 @@
import { omit } from 'lodash/fp';
import expect from '@kbn/expect';
import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils';
import { ALERT_CASE_IDS, ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils';
import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '@kbn/security-solution-plugin/common/constants';
import {
CommentType,
AttributesTypeUser,
@ -41,12 +40,6 @@ import {
createSignalsIndex,
deleteSignalsIndex,
deleteAllAlerts,
getRuleForSignalTesting,
waitForRuleSuccessOrStatus,
waitForSignalsToBePresent,
getSignalsByIds,
createRule,
getQuerySignalIds,
} from '../../../../../detection_engine_api_integration/utils';
import {
globalRead,
@ -57,11 +50,22 @@ import {
secOnly,
secOnlyRead,
superUser,
obsOnlyReadAlerts,
obsSec,
secOnlyReadAlerts,
secSolutionOnlyReadNoIndexAlerts,
} from '../../../../common/lib/authentication/users';
import {
getSecuritySolutionAlerts,
createSecuritySolutionAlerts,
getAlertById,
} from '../../../../common/lib/alerts';
import { User } from '../../../../common/lib/authentication/types';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const es = getService('es');
const log = getService('log');
@ -351,80 +355,476 @@ export default ({ getService }: FtrProviderContext): void => {
});
describe('alerts', () => {
beforeEach(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
await createSignalsIndex(supertest, log);
});
afterEach(async () => {
await deleteSignalsIndex(supertest, log);
await deleteAllAlerts(supertest, log);
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
});
const bulkCreateAlertsAndVerifyAlertStatus = async (
syncAlerts: boolean,
expectedAlertStatus: string
) => {
const rule = getRuleForSignalTesting(['auditbeat-*']);
const postedCase = await createCase(supertest, {
...postCaseReq,
settings: { syncAlerts },
describe('security_solution', () => {
beforeEach(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
await createSignalsIndex(supertest, log);
});
await updateCase({
supertest,
params: {
cases: [
{
id: postedCase.id,
version: postedCase.version,
status: CaseStatuses['in-progress'],
afterEach(async () => {
await deleteSignalsIndex(supertest, log);
await deleteAllAlerts(supertest, log);
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
});
const createCommentAndRefreshIndex = async ({
caseId,
alertId,
alertIndex,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
caseId: string;
alertId: string;
alertIndex: string;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}) => {
await createComment({
supertest: supertestWithoutAuth,
caseId,
params: {
alertId,
index: alertIndex,
rule: {
id: 'id',
name: 'name',
},
],
},
});
const { id } = await createRule(supertest, log, rule);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 1, [id]);
const signals = await getSignalsByIds(supertest, log, [id]);
const alert = signals.hits.hits[0];
expect(alert._source?.[ALERT_WORKFLOW_STATUS]).eql('open');
await createComment({
supertest,
caseId: postedCase.id,
params: {
alertId: alert._id,
index: alert._index,
rule: {
id: 'id',
name: 'name',
owner: 'securitySolutionFixture',
type: CommentType.alert,
},
owner: 'securitySolutionFixture',
type: CommentType.alert,
},
expectedHttpCode,
auth,
});
await es.indices.refresh({ index: alertIndex });
};
const bulkCreateAlertsAndVerifyAlertStatus = async ({
syncAlerts,
expectedAlertStatus,
caseAuth,
attachmentExpectedHttpCode,
attachmentAuth,
}: {
syncAlerts: boolean;
expectedAlertStatus: string;
caseAuth?: { user: User; space: string | null };
attachmentExpectedHttpCode?: number;
attachmentAuth?: { user: User; space: string | null };
}) => {
const postedCase = await createCase(
supertestWithoutAuth,
{
...postCaseReq,
settings: { syncAlerts },
},
200,
caseAuth
);
await updateCase({
supertest: supertestWithoutAuth,
params: {
cases: [
{
id: postedCase.id,
version: postedCase.version,
status: CaseStatuses['in-progress'],
},
],
},
auth: caseAuth,
});
const signals = await createSecuritySolutionAlerts(supertest, log);
const alert = signals.hits.hits[0];
expect(alert._source?.[ALERT_WORKFLOW_STATUS]).eql('open');
await createCommentAndRefreshIndex({
caseId: postedCase.id,
alertId: alert._id,
alertIndex: alert._index,
expectedHttpCode: attachmentExpectedHttpCode,
auth: attachmentAuth,
});
const updatedAlert = await getSecuritySolutionAlerts(supertest, [alert._id]);
expect(updatedAlert.hits.hits[0]._source?.[ALERT_WORKFLOW_STATUS]).eql(
expectedAlertStatus
);
};
const bulkCreateAlertsAndVerifyCaseIdsInAlertSchema = async (totalCases: number) => {
const cases = await Promise.all(
[...Array(totalCases).keys()].map((index) =>
createCase(supertest, {
...postCaseReq,
settings: { syncAlerts: false },
})
)
);
const signals = await createSecuritySolutionAlerts(supertest, log);
const alert = signals.hits.hits[0];
for (const theCase of cases) {
await createCommentAndRefreshIndex({
caseId: theCase.id,
alertId: alert._id,
alertIndex: alert._index,
});
}
const updatedAlert = await getSecuritySolutionAlerts(supertest, [alert._id]);
const caseIds = cases.map((theCase) => theCase.id);
expect(updatedAlert.hits.hits[0]._source?.[ALERT_CASE_IDS]).eql(caseIds);
return { updatedAlert, cases };
};
it('should change the status of the alert if sync alert is on', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: true,
expectedAlertStatus: 'acknowledged',
});
});
await es.indices.refresh({ index: alert._index });
it('should NOT change the status of the alert if sync alert is off', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: false,
expectedAlertStatus: 'open',
});
});
const { body: updatedAlert } = await supertest
.post(DETECTION_ENGINE_QUERY_SIGNALS_URL)
.set('kbn-xsrf', 'true')
.send(getQuerySignalIds([alert._id]))
.expect(200);
it('should change the status of the alert when the user has write access to the indices and only read access to the siem solution', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: true,
expectedAlertStatus: 'acknowledged',
caseAuth: {
user: superUser,
space: 'space1',
},
attachmentAuth: { user: secOnlyReadAlerts, space: 'space1' },
});
});
expect(updatedAlert.hits.hits[0]._source[ALERT_WORKFLOW_STATUS]).eql(expectedAlertStatus);
};
it('should NOT change the status of the alert when the user does NOT have access to the alert', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: true,
expectedAlertStatus: 'open',
caseAuth: {
user: superUser,
space: 'space1',
},
attachmentExpectedHttpCode: 403,
attachmentAuth: { user: obsSec, space: 'space1' },
});
});
it('should change the status of the alert if sync alert is on', async () => {
await bulkCreateAlertsAndVerifyAlertStatus(true, 'acknowledged');
it('should NOT change the status of the alert when the user has read access to the kibana feature but no read access to the ES index', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: true,
expectedAlertStatus: 'open',
caseAuth: {
user: superUser,
space: 'space1',
},
attachmentExpectedHttpCode: 500,
attachmentAuth: { user: secSolutionOnlyReadNoIndexAlerts, space: 'space1' },
});
});
it('should add the case ID to the alert schema', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(1);
});
it('should add multiple case ids to the alert schema', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(2);
});
it('should remove cases with the same ID from the case_ids alerts field', async () => {
const { updatedAlert, cases } = await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(1);
const postedCase = cases[0];
const alert = updatedAlert.hits.hits[0];
await createCommentAndRefreshIndex({
caseId: postedCase.id,
alertId: alert._id,
alertIndex: alert._index,
});
const updatedAlertSecondTime = await getSecuritySolutionAlerts(supertest, [alert._id]);
expect(updatedAlertSecondTime.hits.hits[0]._source?.[ALERT_CASE_IDS]).eql([
postedCase.id,
]);
});
it('should not add more than 10 cases to an alert', async () => {
const { updatedAlert } = await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(10);
const alert = updatedAlert.hits.hits[0];
const postedCase = await createCase(supertest, {
...postCaseReq,
settings: { syncAlerts: false },
});
await createCommentAndRefreshIndex({
caseId: postedCase.id,
alertId: alert._id,
alertIndex: alert._index,
expectedHttpCode: 400,
});
});
it('should add the case ID to the alert schema when the user has write access to the indices and only read access to the siem solution', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
const signals = await createSecuritySolutionAlerts(supertest, log);
const alert = signals.hits.hits[0];
await createCommentAndRefreshIndex({
caseId: postedCase.id,
alertId: alert._id,
alertIndex: alert._index,
expectedHttpCode: 200,
auth: { user: secOnlyReadAlerts, space: 'space1' },
});
});
it('should NOT add the case ID to the alert schema when the user does NOT have access to the alert', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
const signals = await createSecuritySolutionAlerts(supertest, log);
const alert = signals.hits.hits[0];
await createCommentAndRefreshIndex({
caseId: postedCase.id,
alertId: alert._id,
alertIndex: alert._index,
expectedHttpCode: 403,
auth: { user: obsSec, space: 'space1' },
});
});
it('should add the case ID to the alert schema when the user has read access to the kibana feature but no read access to the ES index', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
const signals = await createSecuritySolutionAlerts(supertest, log);
const alert = signals.hits.hits[0];
await createCommentAndRefreshIndex({
caseId: postedCase.id,
alertId: alert._id,
alertIndex: alert._index,
expectedHttpCode: 200,
auth: { user: secSolutionOnlyReadNoIndexAlerts, space: 'space1' },
});
});
});
it('should NOT change the status of the alert if sync alert is off', async () => {
await bulkCreateAlertsAndVerifyAlertStatus(false, 'open');
describe('observability', () => {
const alertId = 'NoxgpHkBqbdrfX07MqXV';
const apmIndex = '.alerts-observability.apm.alerts';
beforeEach(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
});
afterEach(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts');
});
const bulkCreateAlertsAndVerifyCaseIdsInAlertSchema = async (totalCases: number) => {
const cases = await Promise.all(
[...Array(totalCases).keys()].map((index) =>
createCase(supertest, {
...postCaseReq,
owner: 'observabilityFixture',
settings: { syncAlerts: false },
})
)
);
for (const theCase of cases) {
await createComment({
supertest,
caseId: theCase.id,
params: {
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'observabilityFixture',
type: CommentType.alert,
},
});
}
const alert = await getAlertById({
supertest,
id: alertId,
index: apmIndex,
auth: { user: superUser, space: 'space1' },
});
const caseIds = cases.map((theCase) => theCase.id);
expect(alert['kibana.alert.case_ids']).eql(caseIds);
return { alert, cases };
};
it('should add the case ID to the alert schema', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(1);
});
it('should add multiple case ids to the alert schema', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(2);
});
it('should remove cases with the same ID from the case_ids alerts field', async () => {
const { cases } = await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(1);
const postedCase = cases[0];
await createComment({
supertest,
caseId: postedCase.id,
params: {
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'observabilityFixture',
type: CommentType.alert,
},
});
const alert = await getAlertById({
supertest,
id: alertId,
index: apmIndex,
auth: { user: superUser, space: 'space1' },
});
expect(alert['kibana.alert.case_ids']).eql([postedCase.id]);
});
it('should not add more than 10 cases to an alert', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(10);
const postedCase = await createCase(supertest, {
...postCaseReq,
settings: { syncAlerts: false },
});
await createComment({
supertest,
caseId: postedCase.id,
params: {
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'observabilityFixture',
type: CommentType.alert,
},
expectedHttpCode: 400,
});
});
it('should add the case ID to the alert schema when the user has read access only', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
owner: 'observabilityFixture',
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
await createComment({
supertest: supertestWithoutAuth,
caseId: postedCase.id,
params: {
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'observabilityFixture',
type: CommentType.alert,
},
auth: { user: obsOnlyReadAlerts, space: 'space1' },
expectedHttpCode: 200,
});
});
it('should NOT add the case ID to the alert schema when the user does NOT have access to the alert', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
owner: 'observabilityFixture',
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
await createComment({
supertest: supertestWithoutAuth,
caseId: postedCase.id,
params: {
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'observabilityFixture',
type: CommentType.alert,
},
auth: { user: obsSec, space: 'space1' },
expectedHttpCode: 403,
});
});
});
});
@ -468,8 +868,6 @@ export default ({ getService }: FtrProviderContext): void => {
});
describe('rbac', () => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
afterEach(async () => {
await deleteAllCaseItems(es);
});

View file

@ -7,14 +7,12 @@
import { omit } from 'lodash/fp';
import expect from '@kbn/expect';
import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils';
import { ALERT_CASE_IDS, ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils';
import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '@kbn/security-solution-plugin/common/constants';
import {
BulkCreateCommentRequest,
CaseResponse,
CaseStatuses,
CommentRequest,
CommentType,
} from '@kbn/cases-plugin/common/api';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
@ -40,27 +38,32 @@ import {
createSignalsIndex,
deleteSignalsIndex,
deleteAllAlerts,
getRuleForSignalTesting,
waitForRuleSuccessOrStatus,
waitForSignalsToBePresent,
getSignalsByIds,
createRule,
getQuerySignalIds,
} from '../../../../../detection_engine_api_integration/utils';
import {
globalRead,
noKibanaPrivileges,
obsOnly,
obsOnlyRead,
obsOnlyReadAlerts,
obsSec,
obsSecRead,
secOnly,
secOnlyRead,
secOnlyReadAlerts,
secSolutionOnlyReadNoIndexAlerts,
superUser,
} from '../../../../common/lib/authentication/users';
import {
getSecuritySolutionAlerts,
createSecuritySolutionAlerts,
getAlertById,
} from '../../../../common/lib/alerts';
import { User } from '../../../../common/lib/authentication/types';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const es = getService('es');
const log = getService('log');
@ -468,92 +471,492 @@ export default ({ getService }: FtrProviderContext): void => {
});
describe('alerts', () => {
beforeEach(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
await createSignalsIndex(supertest, log);
});
afterEach(async () => {
await deleteSignalsIndex(supertest, log);
await deleteAllAlerts(supertest, log);
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
});
const bulkCreateAlertsAndVerifyAlertStatus = async (
syncAlerts: boolean,
expectedAlertStatus: string
) => {
const rule = getRuleForSignalTesting(['auditbeat-*']);
const postedCase = await createCase(supertest, {
...postCaseReq,
settings: { syncAlerts },
describe('security_solution', () => {
beforeEach(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
await createSignalsIndex(supertest, log);
});
await updateCase({
supertest,
params: {
cases: [
{
id: postedCase.id,
version: postedCase.version,
status: CaseStatuses['in-progress'],
afterEach(async () => {
await deleteSignalsIndex(supertest, log);
await deleteAllAlerts(supertest, log);
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
});
const bulkCreateAttachmentsAndRefreshIndex = async ({
caseId,
alerts,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
caseId: string;
alerts: Array<{ id: string; index: string }>;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}) => {
await bulkCreateAttachments({
supertest: supertestWithoutAuth,
caseId,
params: alerts.map((alert) => ({
alertId: alert.id,
index: alert.index,
rule: {
id: 'id',
name: 'name',
},
],
},
});
const { id } = await createRule(supertest, log, rule);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 1, [id]);
const signals = await getSignalsByIds(supertest, log, [id]);
const attachments: CommentRequest[] = [];
const indices: string[] = [];
const ids: string[] = [];
signals.hits.hits.forEach((alert) => {
expect(alert._source?.[ALERT_WORKFLOW_STATUS]).eql('open');
attachments.push({
alertId: alert._id,
index: alert._index,
rule: {
id: 'id',
name: 'name',
},
owner: 'securitySolutionFixture',
type: CommentType.alert,
owner: 'securitySolutionFixture',
type: CommentType.alert,
})),
expectedHttpCode,
auth,
});
indices.push(alert._index);
ids.push(alert._id);
});
await es.indices.refresh({ index: alerts.map((alert) => alert.index) });
};
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: attachments,
});
const bulkCreateAlertsAndVerifyAlertStatus = async ({
syncAlerts,
expectedAlertStatus,
caseAuth,
attachmentExpectedHttpCode,
attachmentAuth,
}: {
syncAlerts: boolean;
expectedAlertStatus: string;
caseAuth?: { user: User; space: string | null };
attachmentExpectedHttpCode?: number;
attachmentAuth?: { user: User; space: string | null };
}) => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
settings: { syncAlerts },
},
200,
caseAuth
);
await es.indices.refresh({ index: indices });
await updateCase({
supertest,
params: {
cases: [
{
id: postedCase.id,
version: postedCase.version,
status: CaseStatuses['in-progress'],
},
],
},
auth: caseAuth,
});
const { body: updatedAlerts } = await supertest
.post(DETECTION_ENGINE_QUERY_SIGNALS_URL)
.set('kbn-xsrf', 'true')
.send(getQuerySignalIds(ids))
.expect(200);
const signals = await createSecuritySolutionAlerts(supertest, log);
updatedAlerts.hits.hits.forEach(
(alert: { _source: { 'kibana.alert.workflow_status': string } }) => {
expect(alert._source[ALERT_WORKFLOW_STATUS]).eql(expectedAlertStatus);
const alerts: Array<{ id: string; index: string }> = [];
const indices: string[] = [];
const ids: string[] = [];
signals.hits.hits.forEach((alert) => {
expect(alert._source?.[ALERT_WORKFLOW_STATUS]).eql('open');
alerts.push({
id: alert._id,
index: alert._index,
});
indices.push(alert._index);
ids.push(alert._id);
});
await bulkCreateAttachmentsAndRefreshIndex({
caseId: postedCase.id,
alerts,
auth: attachmentAuth,
expectedHttpCode: attachmentExpectedHttpCode,
});
const updatedAlerts = await getSecuritySolutionAlerts(supertest, ids);
updatedAlerts.hits.hits.forEach((alert) => {
expect(alert._source?.[ALERT_WORKFLOW_STATUS]).eql(expectedAlertStatus);
});
};
const bulkCreateAlertsAndVerifyCaseIdsInAlertSchema = async (totalCases: number) => {
const cases = await Promise.all(
[...Array(totalCases).keys()].map((index) =>
createCase(supertest, {
...postCaseReq,
settings: { syncAlerts: false },
})
)
);
const signals = await createSecuritySolutionAlerts(supertest, log);
const alert = signals.hits.hits[0];
for (const theCase of cases) {
await bulkCreateAttachmentsAndRefreshIndex({
caseId: theCase.id,
alerts: [{ id: alert._id, index: alert._index }],
});
}
);
};
it('should change the status of the alerts if sync alert is on', async () => {
await bulkCreateAlertsAndVerifyAlertStatus(true, 'acknowledged');
await es.indices.refresh({ index: alert._index });
const updatedAlert = await getSecuritySolutionAlerts(supertest, [alert._id]);
const caseIds = cases.map((theCase) => theCase.id);
expect(updatedAlert.hits.hits[0]._source?.[ALERT_CASE_IDS]).eql(caseIds);
return { updatedAlert, cases };
};
it('should change the status of the alerts if sync alert is on', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: true,
expectedAlertStatus: 'acknowledged',
});
});
it('should NOT change the status of the alert if sync alert is off', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: false,
expectedAlertStatus: 'open',
});
});
it('should change the status of the alert when the user has write access to the indices and only read access to the siem solution', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: true,
expectedAlertStatus: 'acknowledged',
caseAuth: {
user: superUser,
space: 'space1',
},
attachmentAuth: { user: secOnlyReadAlerts, space: 'space1' },
});
});
it('should NOT change the status of the alert when the user does NOT have access to the alert', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: true,
expectedAlertStatus: 'open',
caseAuth: {
user: superUser,
space: 'space1',
},
attachmentExpectedHttpCode: 403,
attachmentAuth: { user: obsSec, space: 'space1' },
});
});
it('should NOT change the status of the alert when the user has read access to the kibana feature but no read access to the ES index', async () => {
await bulkCreateAlertsAndVerifyAlertStatus({
syncAlerts: true,
expectedAlertStatus: 'open',
caseAuth: {
user: superUser,
space: 'space1',
},
attachmentExpectedHttpCode: 500,
attachmentAuth: { user: secSolutionOnlyReadNoIndexAlerts, space: 'space1' },
});
});
it('should add the case ID to the alert schema', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(1);
});
it('should add multiple case ids to the alert schema', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(2);
});
it('should remove cases with the same ID from the case_ids alerts field', async () => {
const { updatedAlert, cases } = await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(1);
const postedCase = cases[0];
const alert = updatedAlert.hits.hits[0];
await bulkCreateAttachmentsAndRefreshIndex({
caseId: postedCase.id,
alerts: [{ id: alert._id, index: alert._index }],
});
const updatedAlertSecondTime = await getSecuritySolutionAlerts(supertest, [alert._id]);
expect(updatedAlertSecondTime.hits.hits[0]._source?.[ALERT_CASE_IDS]).eql([
postedCase.id,
]);
});
it('should not add more than 10 cases to an alert', async () => {
const { updatedAlert } = await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(10);
const alert = updatedAlert.hits.hits[0];
const postedCase = await createCase(supertest, {
...postCaseReq,
settings: { syncAlerts: false },
});
await bulkCreateAttachmentsAndRefreshIndex({
caseId: postedCase.id,
alerts: [{ id: alert._id, index: alert._index }],
expectedHttpCode: 400,
});
});
it('should add the case ID to the alert schema when the user has read access only', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
const signals = await createSecuritySolutionAlerts(supertest, log);
const alert = signals.hits.hits[0];
await bulkCreateAttachmentsAndRefreshIndex({
caseId: postedCase.id,
alerts: [{ id: alert._id, index: alert._index }],
expectedHttpCode: 200,
auth: { user: secOnlyReadAlerts, space: 'space1' },
});
});
it('should NOT add the case ID to the alert schema when the user does NOT have access to the alert', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
const signals = await createSecuritySolutionAlerts(supertest, log);
const alert = signals.hits.hits[0];
await bulkCreateAttachmentsAndRefreshIndex({
caseId: postedCase.id,
alerts: [{ id: alert._id, index: alert._index }],
expectedHttpCode: 403,
auth: { user: obsSec, space: 'space1' },
});
});
it('should add the case ID to the alert schema when the user has read access to the kibana feature but no read access to the ES index', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
const signals = await createSecuritySolutionAlerts(supertest, log);
const alert = signals.hits.hits[0];
await bulkCreateAttachmentsAndRefreshIndex({
caseId: postedCase.id,
alerts: [{ id: alert._id, index: alert._index }],
expectedHttpCode: 200,
auth: { user: secSolutionOnlyReadNoIndexAlerts, space: 'space1' },
});
});
});
it('should NOT change the status of the alert if sync alert is off', async () => {
await bulkCreateAlertsAndVerifyAlertStatus(false, 'open');
describe('observability', () => {
const alertId = 'NoxgpHkBqbdrfX07MqXV';
const apmIndex = '.alerts-observability.apm.alerts';
beforeEach(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
});
afterEach(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts');
});
const bulkCreateAlertsAndVerifyCaseIdsInAlertSchema = async (totalCases: number) => {
const cases = await Promise.all(
[...Array(totalCases).keys()].map((index) =>
createCase(supertest, {
...postCaseReq,
owner: 'observabilityFixture',
settings: { syncAlerts: false },
})
)
);
for (const theCase of cases) {
await bulkCreateAttachments({
supertest,
caseId: theCase.id,
params: [
{
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'observabilityFixture',
type: CommentType.alert,
},
],
});
}
const alert = await getAlertById({
supertest,
id: alertId,
index: apmIndex,
auth: { user: superUser, space: 'space1' },
});
const caseIds = cases.map((theCase) => theCase.id);
expect(alert['kibana.alert.case_ids']).eql(caseIds);
return { alert, cases };
};
it('should add the case ID to the alert schema', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(1);
});
it('should add multiple case ids to the alert schema', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(2);
});
it('should remove cases with the same ID from the case_ids alerts field', async () => {
const { cases } = await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(1);
const postedCase = cases[0];
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [
{
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'observabilityFixture',
type: CommentType.alert,
},
],
});
const alert = await getAlertById({
supertest,
id: alertId,
index: apmIndex,
auth: { user: superUser, space: 'space1' },
});
expect(alert['kibana.alert.case_ids']).eql([postedCase.id]);
});
it('should not add more than 10 cases to an alert', async () => {
await bulkCreateAlertsAndVerifyCaseIdsInAlertSchema(10);
const postedCase = await createCase(supertest, {
...postCaseReq,
settings: { syncAlerts: false },
});
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [
{
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'securitySolutionFixture',
type: CommentType.alert,
},
],
expectedHttpCode: 400,
});
});
it('should add the case ID to the alert schema when the user has read access only', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
owner: 'observabilityFixture',
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
await bulkCreateAttachments({
supertest: supertestWithoutAuth,
caseId: postedCase.id,
params: [
{
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'observabilityFixture',
type: CommentType.alert,
},
],
auth: { user: obsOnlyReadAlerts, space: 'space1' },
expectedHttpCode: 200,
});
});
it('should NOT add the case ID to the alert schema when the user does NOT have access to the alert', async () => {
const postedCase = await createCase(
supertest,
{
...postCaseReq,
owner: 'observabilityFixture',
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
);
await bulkCreateAttachments({
supertest: supertestWithoutAuth,
caseId: postedCase.id,
params: [
{
alertId,
index: apmIndex,
rule: {
id: 'id',
name: 'name',
},
owner: 'observabilityFixture',
type: CommentType.alert,
},
],
auth: { user: obsSec, space: 'space1' },
expectedHttpCode: 403,
});
});
});
});
@ -602,8 +1005,6 @@ export default ({ getService }: FtrProviderContext): void => {
});
describe('rbac', () => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
afterEach(async () => {
await deleteAllCaseItems(es);
});