diff --git a/x-pack/plugins/observability/public/hooks/use_alert_permission.ts b/x-pack/plugins/observability/public/hooks/use_alert_permission.ts index 509324e00f650..2c2837c4bda82 100644 --- a/x-pack/plugins/observability/public/hooks/use_alert_permission.ts +++ b/x-pack/plugins/observability/public/hooks/use_alert_permission.ts @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; import { RecursiveReadonly } from '@kbn/utility-types'; +import { Capabilities } from '../../../../../src/core/types'; export interface UseGetUserAlertsPermissionsProps { crud: boolean; @@ -15,8 +16,29 @@ export interface UseGetUserAlertsPermissionsProps { featureId: string | null; } +export const getAlertsPermissions = ( + uiCapabilities: RecursiveReadonly, + featureId: string +) => { + if (!featureId || !uiCapabilities[featureId]) { + return { + crud: false, + read: false, + loading: false, + featureId, + }; + } + + return { + crud: uiCapabilities[featureId].save as boolean, + read: uiCapabilities[featureId].show as boolean, + loading: false, + featureId, + }; +}; + export const useGetUserAlertsPermissions = ( - uiCapabilities: RecursiveReadonly>, + uiCapabilities: RecursiveReadonly, featureId?: string ): UseGetUserAlertsPermissionsProps => { const [alertsPermissions, setAlertsPermissions] = useState({ @@ -39,20 +61,7 @@ export const useGetUserAlertsPermissions = ( if (currentAlertPermissions.featureId === featureId) { return currentAlertPermissions; } - const capabilitiesCanUserCRUD: boolean = - typeof uiCapabilities[featureId].save === 'boolean' - ? uiCapabilities[featureId].save - : false; - const capabilitiesCanUserRead: boolean = - typeof uiCapabilities[featureId].show === 'boolean' - ? uiCapabilities[featureId].show - : false; - return { - crud: capabilitiesCanUserCRUD, - read: capabilitiesCanUserRead, - loading: false, - featureId, - }; + return getAlertsPermissions(uiCapabilities, featureId); }); } }, [alertsPermissions.featureId, featureId, uiCapabilities]); diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx index 2604d3b0e1c5a..3b62538fa3e30 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx @@ -40,7 +40,10 @@ import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import React, { Suspense, useMemo, useState, useCallback } from 'react'; import { get } from 'lodash'; -import { useGetUserAlertsPermissions } from '../../hooks/use_alert_permission'; +import { + getAlertsPermissions, + useGetUserAlertsPermissions, +} from '../../hooks/use_alert_permission'; import type { TimelinesUIStart, TGridType, SortDirection } from '../../../../timelines/public'; import { useStatusBulkActionItems } from '../../../../timelines/public'; import type { TopAlert } from './'; @@ -279,12 +282,22 @@ function ObservabilityActions({ export function AlertsTableTGrid(props: AlertsTableTGridProps) { const { indexNames, rangeFrom, rangeTo, kuery, workflowStatus, setRefetch, addToQuery } = props; - const { timelines } = useKibana<{ timelines: TimelinesUIStart }>().services; + const { + timelines, + application: { capabilities }, + } = useKibana().services; const [flyoutAlert, setFlyoutAlert] = useState(undefined); const casePermissions = useGetUserCasesPermissions(); + const hasAlertsCrudPermissions = useCallback( + (featureId: string) => { + return getAlertsPermissions(capabilities, featureId).crud; + }, + [capabilities] + ); + const leadingControlColumns = useMemo(() => { return [ { @@ -324,6 +337,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { defaultCellActions: getDefaultCellActions({ addToQuery }), end: rangeTo, filters: [], + hasAlertsCrudPermissions, indexNames, itemsPerPageOptions: [10, 25, 50], loadingText: i18n.translate('xpack.observability.alertsTable.loadingTextLabel', { @@ -358,14 +372,15 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { }; }, [ casePermissions, + addToQuery, + rangeTo, + hasAlertsCrudPermissions, indexNames, + workflowStatus, kuery, - leadingControlColumns, rangeFrom, - rangeTo, setRefetch, - workflowStatus, - addToQuery, + leadingControlColumns, ]); const handleFlyoutClose = () => setFlyoutAlert(undefined); const { observabilityRuleTypeRegistry } = usePluginContext(); diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts index c585d93330b20..4bb9928aa6b97 100644 --- a/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts @@ -30,6 +30,7 @@ export interface TimelineNonEcsData { } export interface TimelineEventsAllStrategyResponse extends IEsSearchResponse { + consumers: Record; edges: TimelineEdges[]; totalCount: number; pageInfo: Pick; diff --git a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts index bd864b9d97487..e8ba2718df69b 100644 --- a/x-pack/plugins/timelines/common/types/timeline/actions/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/actions/index.ts @@ -20,6 +20,7 @@ export interface ActionProps { columnId: string; columnValues: string; checked: boolean; + disabled?: boolean; onRowSelected: OnRowSelected; eventId: string; loadingEventIds: Readonly; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.tsx index cc8ec06d18dbd..0d750a002914b 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/control_columns/checkbox.tsx @@ -16,15 +16,19 @@ export const RowCheckBox = ({ checked, ariaRowindex, columnValues, + disabled, loadingEventIds, }: ActionProps) => { const handleSelectEvent = useCallback( - (event: React.ChangeEvent) => - onRowSelected({ - eventIds: [eventId], - isSelected: event.currentTarget.checked, - }), - [eventId, onRowSelected] + (event: React.ChangeEvent) => { + if (!disabled) { + onRowSelected({ + eventIds: [eventId], + isSelected: event.currentTarget.checked, + }); + } + }, + [eventId, onRowSelected, disabled] ); return loadingEventIds.includes(eventId) ? ( @@ -33,7 +37,8 @@ export const RowCheckBox = ({ diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx index 6c98884451d8f..09e773fff47a1 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils'; import { isEmpty } from 'lodash/fp'; import { EuiDataGridCellValueElementProps } from '@elastic/eui'; @@ -39,12 +40,24 @@ export const stringifyEvent = (ecs: Ecs): string => JSON.stringify(ecs, omitType export const getEventIdToDataMapping = ( timelineData: TimelineItem[], eventIds: string[], - fieldsToKeep: string[] + fieldsToKeep: string[], + hasAlertsCrud: boolean, + hasAlertsCrudPermissionsByFeatureId?: (featureId: string) => boolean ): Record => timelineData.reduce((acc, v) => { - const fvm = eventIds.includes(v._id) - ? { [v._id]: v.data.filter((ti) => fieldsToKeep.includes(ti.field)) } - : {}; + // FUTURE DEVELOPER + // We only have one featureId for security solution therefore we can just use hasAlertsCrud + // but for o11y we can multiple featureIds so we need to check every consumer + // of the alert to see if they have the permission to update the alert + const alertConsumers = v.data.find((d) => d.field === ALERT_RULE_CONSUMER)?.value ?? []; + const hasPermissions = hasAlertsCrudPermissionsByFeatureId + ? alertConsumers.some((consumer) => hasAlertsCrudPermissionsByFeatureId(consumer)) + : hasAlertsCrud; + + const fvm = + hasPermissions && eventIds.includes(v._id) + ? { [v._id]: v.data.filter((ti) => fieldsToKeep.includes(ti.field)) } + : {}; return { ...acc, ...fvm, diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index 001e405fc10e0..5867fa987b982 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -32,6 +32,7 @@ import React, { import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { ThemeContext } from 'styled-components'; +import { ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils'; import { TGridCellAction, BulkActionsProp, @@ -103,6 +104,8 @@ interface OwnProps { trailingControlColumns?: ControlColumnProps[]; unit?: (total: number) => React.ReactNode; hasAlertsCrud?: boolean; + hasAlertsCrudPermissions?: (featureId: string) => boolean; + totalSelectAllAlerts?: number; } const defaultUnit = (n: number) => i18n.ALERTS_UNIT(n); @@ -143,6 +146,7 @@ const transformControlColumns = ({ theme, setEventsLoading, setEventsDeleted, + hasAlertsCrudPermissions, }: { actionColumnsWidth: number; columnHeaders: ColumnHeaderOptions[]; @@ -163,6 +167,7 @@ const transformControlColumns = ({ theme: EuiTheme; setEventsLoading: SetEventsLoading; setEventsDeleted: SetEventsDeleted; + hasAlertsCrudPermissions?: (featureId: string) => boolean; }): EuiDataGridControlColumn[] => controlColumns.map( ({ id: columnId, headerCellRender = EmptyHeaderCellRender, rowCellRender, width }, i) => ({ @@ -200,6 +205,12 @@ const transformControlColumns = ({ setCellProps, }: EuiDataGridCellValueElementProps) => { addBuildingBlockStyle(data[rowIndex].ecs, theme, setCellProps); + let disabled = false; + if (columnId === 'checkbox-control-column' && hasAlertsCrudPermissions != null) { + const alertConsumers = + data[rowIndex].data.find((d) => d.field === ALERT_RULE_CONSUMER)?.value ?? []; + disabled = alertConsumers.some((consumer) => !hasAlertsCrudPermissions(consumer)); + } return ( ( trailingControlColumns = EMPTY_CONTROL_COLUMNS, unit = defaultUnit, hasAlertsCrud, + hasAlertsCrudPermissions, + totalSelectAllAlerts, }) => { const dispatch = useDispatch(); const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); @@ -294,12 +308,18 @@ export const BodyComponent = React.memo( ({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => { setSelected({ id, - eventIds: getEventIdToDataMapping(data, eventIds, queryFields), + eventIds: getEventIdToDataMapping( + data, + eventIds, + queryFields, + hasAlertsCrud ?? false, + hasAlertsCrudPermissions + ), isSelected, isSelectAllChecked: isSelected && selectedCount + 1 === data.length, }); }, - [setSelected, id, data, selectedCount, queryFields] + [setSelected, id, data, queryFields, hasAlertsCrud, hasAlertsCrudPermissions, selectedCount] ); const onSelectPage: OnSelectAll = useCallback( @@ -310,13 +330,15 @@ export const BodyComponent = React.memo( eventIds: getEventIdToDataMapping( data, data.map((event) => event._id), - queryFields + queryFields, + hasAlertsCrud ?? false, + hasAlertsCrudPermissions ), isSelected, isSelectAllChecked: isSelected, }) : clearSelected({ id }), - [setSelected, clearSelected, id, data, queryFields] + [setSelected, id, data, queryFields, hasAlertsCrud, hasAlertsCrudPermissions, clearSelected] ); // Sync to selectAll so parent components can select all events @@ -363,7 +385,7 @@ export const BodyComponent = React.memo( ( refetch, showBulkActions, totalItems, + totalSelectAllAlerts, ] ); @@ -400,7 +423,7 @@ export const BodyComponent = React.memo( ( showStyleSelector: false, }), [ - id, alertCountText, + showBulkActions, + id, + totalSelectAllAlerts, totalItems, filterStatus, filterQuery, - browserFields, indexNames, - columnHeaders, - additionalControls, - showBulkActions, onAlertStatusActionSuccess, onAlertStatusActionFailure, refetch, + additionalControls, + browserFields, + columnHeaders, ] ); @@ -544,28 +568,30 @@ export const BodyComponent = React.memo( theme, setEventsLoading, setEventsDeleted, + hasAlertsCrudPermissions, }) ); }, [ + showCheckboxes, + leadingControlColumns, + trailingControlColumns, columnHeaders, data, - id, isEventViewer, - leadingControlColumns, + id, loadingEventIds, onRowSelected, onRuleChange, selectedEventIds, - showCheckboxes, tabType, - trailingControlColumns, isSelectAllChecked, + sort, browserFields, onSelectPage, - sort, theme, setEventsLoading, setEventsDeleted, + hasAlertsCrudPermissions, ]); const columnsWithCellActions: EuiDataGridColumn[] = useMemo( diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx index dd1a62bc726da..c5ba88dc36a63 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx @@ -26,6 +26,7 @@ type Props = EuiDataGridCellValueElementProps & { columnHeaders: ColumnHeaderOptions[]; controlColumn: ControlColumnProps; data: TimelineItem[]; + disabled: boolean; index: number; isEventViewer: boolean; loadingEventIds: Readonly; @@ -44,6 +45,7 @@ const RowActionComponent = ({ columnHeaders, controlColumn, data, + disabled, index, isEventViewer, loadingEventIds, @@ -114,6 +116,7 @@ const RowActionComponent = ({ columnValues={columnValues} data={timelineNonEcsData} data-test-subj="actions" + disabled={disabled} ecsData={ecsData} eventId={eventId} index={index} diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 1be6853e7d0ee..9c755202aea81 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -93,6 +93,7 @@ export interface TGridStandaloneProps { filters: Filter[]; footerText: React.ReactNode; filterStatus: AlertStatus; + hasAlertsCrudPermissions: (featureId: string) => boolean; height?: number; indexNames: string[]; itemsPerPageOptions: number[]; @@ -124,6 +125,7 @@ const TGridStandaloneComponent: React.FC = ({ filters, footerText, filterStatus, + hasAlertsCrudPermissions, indexNames, itemsPerPageOptions, onRuleChange, @@ -202,7 +204,7 @@ const TGridStandaloneComponent: React.FC = ({ const [ loading, - { events, updatedAt, loadPage, pageInfo, refetch, totalCount = 0, inspect }, + { consumers, events, updatedAt, loadPage, pageInfo, refetch, totalCount = 0, inspect }, ] = useTimelineEvents({ docValueFields: [], entityType, @@ -220,6 +222,27 @@ const TGridStandaloneComponent: React.FC = ({ }); setRefetch(refetch); + const { hasAlertsCrud, totalSelectAllAlerts } = useMemo(() => { + return Object.entries(consumers).reduce<{ + hasAlertsCrud: boolean; + totalSelectAllAlerts: number; + }>( + (acc, [featureId, nbrAlerts]) => { + const featureHasPermission = hasAlertsCrudPermissions(featureId); + return { + hasAlertsCrud: featureHasPermission || acc.hasAlertsCrud, + totalSelectAllAlerts: featureHasPermission + ? nbrAlerts + acc.totalSelectAllAlerts + : acc.totalSelectAllAlerts, + }; + }, + { + hasAlertsCrud: false, + totalSelectAllAlerts: 0, + } + ); + }, [consumers, hasAlertsCrudPermissions]); + const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), [deletedEventIds.length, totalCount] @@ -322,6 +345,8 @@ const TGridStandaloneComponent: React.FC = ({ data={nonDeletedEvents} defaultCellActions={defaultCellActions} filterQuery={filterQuery} + hasAlertsCrud={hasAlertsCrud} + hasAlertsCrudPermissions={hasAlertsCrudPermissions} id={STANDALONE_ID} indexNames={indexNames} isEventViewer={true} @@ -340,6 +365,7 @@ const TGridStandaloneComponent: React.FC = ({ itemsPerPage: itemsPerPageStore, })} totalItems={totalCountMinusDeleted} + totalSelectAllAlerts={totalSelectAllAlerts} unit={unit} filterStatus={filterStatus} trailingControlColumns={trailingControlColumns} diff --git a/x-pack/plugins/timelines/public/container/index.tsx b/x-pack/plugins/timelines/public/container/index.tsx index 81578a001f6a4..87359516a9db9 100644 --- a/x-pack/plugins/timelines/public/container/index.tsx +++ b/x-pack/plugins/timelines/public/container/index.tsx @@ -51,6 +51,7 @@ export const detectionsTimelineIds = [ type Refetch = () => void; export interface TimelineArgs { + consumers: Record; events: TimelineItem[]; id: string; inspect: InspectResponse; @@ -170,6 +171,7 @@ export const useTimelineEvents = ({ ); const [timelineResponse, setTimelineResponse] = useState({ + consumers: {}, id, inspect: { dsl: [], @@ -215,6 +217,7 @@ export const useTimelineEvents = ({ setTimelineResponse((prevResponse) => { const newTimelineResponse = { ...prevResponse, + consumers: response.consumers, events: getTimelineEvents(response.edges), inspect: getInspectResponse(response, prevResponse.inspect), pageInfo: response.pageInfo, @@ -346,6 +349,7 @@ export const useTimelineEvents = ({ useEffect(() => { if (isEmpty(filterQuery)) { setTimelineResponse({ + consumers: {}, id, inspect: { dsl: [], diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts index ffe3ea5abd689..8e8798d89a64c 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils'; // import { CTI_ROW_RENDERER_FIELDS } from '../../../../../../common/cti/constants'; // TODO: share with security_solution/common/cti/constants.ts @@ -40,6 +41,7 @@ export const CTI_ROW_RENDERER_FIELDS = [ ]; export const TIMELINE_EVENTS_FIELDS = [ + ALERT_RULE_CONSUMER, '@timestamp', 'signal.status', 'signal.group.id', diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/index.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/index.ts index 4ca8edf9d4539..8f4861ab43b47 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/index.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { cloneDeep } from 'lodash/fp'; +import { cloneDeep, getOr } from 'lodash/fp'; import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; import { @@ -38,6 +38,7 @@ export const timelineEventsAll: TimelineFactory = { let { fieldRequested, ...queryOptions } = cloneDeep(options); queryOptions.fields = buildFieldsRequest(fieldRequested, queryOptions.excludeEcsData); const { activePage, querySize } = options.pagination; + const buckets = getOr([], 'aggregations.consumers.buckets', response.rawResponse); const totalCount = response.rawResponse.hits.total || 0; const hits = response.rawResponse.hits.hits; @@ -61,12 +62,21 @@ export const timelineEventsAll: TimelineFactory = { ) ); + const consumers = buckets.reduce( + (acc: Record, b: { key: string; doc_count: number }) => ({ + ...acc, + [b.key]: b.doc_count, + }), + {} + ); + const inspect = { dsl: [inspectStringifyObject(buildTimelineEventsAllQuery(queryOptions))], }; return { ...response, + consumers, inspect, edges, // @ts-expect-error code doesn't handle TotalHits diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts index ba9aa845f4b9b..e9261e8b116be 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils'; import { isEmpty } from 'lodash/fp'; import { @@ -67,6 +68,11 @@ export const buildTimelineEventsAllQuery = ({ ignoreUnavailable: true, body: { ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggregations: { + consumers: { + terms: { field: ALERT_RULE_CONSUMER }, + }, + }, query: { bool: { filter, diff --git a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx b/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx index d27f330b57915..adc10ae0a4161 100644 --- a/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx +++ b/x-pack/test/plugin_functional/plugins/timelines_test/public/applications/timelines_test/index.tsx @@ -54,6 +54,8 @@ const AppRoot = React.memo( refetch.current = _refetch; }, []); + const hasAlertsCrudPermissions = useCallback(() => true, []); + return ( @@ -73,6 +75,7 @@ const AppRoot = React.memo( end: '', footerText: 'Events', filters: [], + hasAlertsCrudPermissions, itemsPerPageOptions: [1, 2, 3], loadingText: 'Loading events', renderCellValue: () =>
test
,