[RAC] [o11y] add permission in alerts table from kibana privilege/consumer (#109759)

* add alert permission in o11y

* review I

* review II

* fix selection all when checkbox disabled

* fix selected on bulk actions
This commit is contained in:
Xavier Mouligneau 2021-08-24 23:32:40 -04:00 committed by GitHub
parent a161c2b7d8
commit 6a1a38b346
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 177 additions and 53 deletions

View file

@ -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<Capabilities>,
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<Record<string, any>>,
uiCapabilities: RecursiveReadonly<Capabilities>,
featureId?: string
): UseGetUserAlertsPermissionsProps => {
const [alertsPermissions, setAlertsPermissions] = useState<UseGetUserAlertsPermissionsProps>({
@ -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]);

View file

@ -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<CoreStart & { timelines: TimelinesUIStart }>().services;
const [flyoutAlert, setFlyoutAlert] = useState<TopAlert | undefined>(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,
indexNames,
kuery,
leadingControlColumns,
rangeFrom,
rangeTo,
setRefetch,
workflowStatus,
addToQuery,
rangeTo,
hasAlertsCrudPermissions,
indexNames,
workflowStatus,
kuery,
rangeFrom,
setRefetch,
leadingControlColumns,
]);
const handleFlyoutClose = () => setFlyoutAlert(undefined);
const { observabilityRuleTypeRegistry } = usePluginContext();

View file

@ -30,6 +30,7 @@ export interface TimelineNonEcsData {
}
export interface TimelineEventsAllStrategyResponse extends IEsSearchResponse {
consumers: Record<string, number>;
edges: TimelineEdges[];
totalCount: number;
pageInfo: Pick<PaginationInputPaginated, 'activePage' | 'querySize'>;

View file

@ -20,6 +20,7 @@ export interface ActionProps {
columnId: string;
columnValues: string;
checked: boolean;
disabled?: boolean;
onRowSelected: OnRowSelected;
eventId: string;
loadingEventIds: Readonly<string[]>;

View file

@ -16,15 +16,19 @@ export const RowCheckBox = ({
checked,
ariaRowindex,
columnValues,
disabled,
loadingEventIds,
}: ActionProps) => {
const handleSelectEvent = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) =>
onRowSelected({
eventIds: [eventId],
isSelected: event.currentTarget.checked,
}),
[eventId, onRowSelected]
(event: React.ChangeEvent<HTMLInputElement>) => {
if (!disabled) {
onRowSelected({
eventIds: [eventId],
isSelected: event.currentTarget.checked,
});
}
},
[eventId, onRowSelected, disabled]
);
return loadingEventIds.includes(eventId) ? (
@ -33,7 +37,8 @@ export const RowCheckBox = ({
<EuiCheckbox
data-test-subj="select-event"
id={eventId}
checked={checked}
checked={checked && !disabled}
disabled={disabled}
onChange={handleSelectEvent}
aria-label={i18n.CHECKBOX_FOR_ROW({ ariaRowindex, columnValues, checked })}
/>

View file

@ -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<string, TimelineNonEcsData[]> =>
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,

View file

@ -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 (
<RowAction
@ -207,6 +218,7 @@ const transformControlColumns = ({
columnHeaders={columnHeaders}
controlColumn={controlColumns[i]}
data={data}
disabled={disabled}
index={i}
isDetails={isDetails}
isExpanded={isExpanded}
@ -275,6 +287,8 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
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<StatefulBodyProps>(
({ 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<StatefulBodyProps>(
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<StatefulBodyProps>(
<StatefulAlertStatusBulkActions
data-test-subj="bulk-actions"
id={id}
totalItems={totalItems}
totalItems={totalSelectAllAlerts ?? totalItems}
filterStatus={filterStatus}
query={filterQuery}
indexName={indexNames.join()}
@ -386,6 +408,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
refetch,
showBulkActions,
totalItems,
totalSelectAllAlerts,
]
);
@ -400,7 +423,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
<StatefulAlertStatusBulkActions
data-test-subj="bulk-actions"
id={id}
totalItems={totalItems}
totalItems={totalSelectAllAlerts ?? totalItems}
filterStatus={filterStatus}
query={filterQuery}
indexName={indexNames.join()}
@ -438,19 +461,20 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
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<StatefulBodyProps>(
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(

View file

@ -26,6 +26,7 @@ type Props = EuiDataGridCellValueElementProps & {
columnHeaders: ColumnHeaderOptions[];
controlColumn: ControlColumnProps;
data: TimelineItem[];
disabled: boolean;
index: number;
isEventViewer: boolean;
loadingEventIds: Readonly<string[]>;
@ -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}

View file

@ -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<TGridStandaloneProps> = ({
filters,
footerText,
filterStatus,
hasAlertsCrudPermissions,
indexNames,
itemsPerPageOptions,
onRuleChange,
@ -202,7 +204,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
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<TGridStandaloneProps> = ({
});
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<TGridStandaloneProps> = ({
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<TGridStandaloneProps> = ({
itemsPerPage: itemsPerPageStore,
})}
totalItems={totalCountMinusDeleted}
totalSelectAllAlerts={totalSelectAllAlerts}
unit={unit}
filterStatus={filterStatus}
trailingControlColumns={trailingControlColumns}

View file

@ -51,6 +51,7 @@ export const detectionsTimelineIds = [
type Refetch = () => void;
export interface TimelineArgs {
consumers: Record<string, number>;
events: TimelineItem[];
id: string;
inspect: InspectResponse;
@ -170,6 +171,7 @@ export const useTimelineEvents = ({
);
const [timelineResponse, setTimelineResponse] = useState<TimelineArgs>({
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: [],

View file

@ -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',

View file

@ -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<TimelineEventsQueries.all> = {
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<TimelineEventsQueries.all> = {
)
);
const consumers = buckets.reduce(
(acc: Record<string, number>, 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

View file

@ -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,

View file

@ -54,6 +54,8 @@ const AppRoot = React.memo(
refetch.current = _refetch;
}, []);
const hasAlertsCrudPermissions = useCallback(() => true, []);
return (
<I18nProvider>
<Router history={parameters.history}>
@ -73,6 +75,7 @@ const AppRoot = React.memo(
end: '',
footerText: 'Events',
filters: [],
hasAlertsCrudPermissions,
itemsPerPageOptions: [1, 2, 3],
loadingText: 'Loading events',
renderCellValue: () => <div data-test-subj="timeline-wrapper">test</div>,