Implement missing privileges callout component (#98125)

This commit is contained in:
Dmitry Shevchenko 2021-05-05 19:45:20 +02:00 committed by GitHub
parent 1322eee98e
commit 24734a39d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 923 additions and 699 deletions

View file

@ -15,7 +15,7 @@ export const READ_ONLY_SAVED_OBJECT_MSG = i18n.translate(
'xpack.cases.readOnlySavedObjectDescription',
{
defaultMessage:
'You only have permissions to view cases. If you need to open and update cases, contact your Kibana administrator.',
'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.',
}
);

View file

@ -26,6 +26,8 @@ export const DEFAULT_REFRESH_RATE_INTERVAL = 'timepicker:refreshIntervalDefaults
export const DEFAULT_APP_TIME_RANGE = 'securitySolution:timeDefaults';
export const DEFAULT_APP_REFRESH_INTERVAL = 'securitySolution:refreshIntervalDefaults';
export const DEFAULT_SIGNALS_INDEX = '.siem-signals';
export const DEFAULT_LISTS_INDEX = '.lists';
export const DEFAULT_ITEMS_INDEX = '.items';
// The DEFAULT_MAX_SIGNALS value exists also in `x-pack/plugins/cases/common/constants.ts`
// If either changes, engineer should ensure both values are updated
export const DEFAULT_MAX_SIGNALS = 100;
@ -50,6 +52,7 @@ export const DEFAULT_RULE_REFRESH_INTERVAL_ON = true;
export const DEFAULT_RULE_REFRESH_INTERVAL_VALUE = 60000; // ms
export const DEFAULT_RULE_REFRESH_IDLE_VALUE = 2700000; // ms
export const DEFAULT_RULE_NOTIFICATION_QUERY_SIZE = 100;
export const SAVED_OBJECTS_MANAGEMENT_FEATURE_ID = 'Saved Objects Management';
// Document path where threat indicator fields are expected. Fields are used
// to enrich signals, and are copied to threat.indicator.

View file

@ -41,8 +41,7 @@ const waitForPageTitleToBeShown = () => {
};
describe('Detections > Callouts', () => {
const ALERTS_CALLOUT = 'read-only-access-to-alerts';
const RULES_CALLOUT = 'read-only-access-to-rules';
const MISSING_PRIVILEGES_CALLOUT = 'missing-user-privileges';
before(() => {
// First, we have to open the app on behalf of a privileged user in order to initialize it.
@ -62,15 +61,15 @@ describe('Detections > Callouts', () => {
});
it('We show one primary callout', () => {
waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary');
waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary');
});
context('When a user clicks Dismiss on the callout', () => {
it('We hide it and persist the dismissal', () => {
waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary');
dismissCallOut(ALERTS_CALLOUT);
waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary');
dismissCallOut(MISSING_PRIVILEGES_CALLOUT);
reloadPage();
getCallOut(ALERTS_CALLOUT).should('not.exist');
getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist');
});
});
});
@ -81,15 +80,15 @@ describe('Detections > Callouts', () => {
});
it('We show one primary callout', () => {
waitForCallOutToBeShown(RULES_CALLOUT, 'primary');
waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary');
});
context('When a user clicks Dismiss on the callout', () => {
it('We hide it and persist the dismissal', () => {
waitForCallOutToBeShown(RULES_CALLOUT, 'primary');
dismissCallOut(RULES_CALLOUT);
waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary');
dismissCallOut(MISSING_PRIVILEGES_CALLOUT);
reloadPage();
getCallOut(RULES_CALLOUT).should('not.exist');
getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist');
});
});
});
@ -106,27 +105,18 @@ describe('Detections > Callouts', () => {
deleteCustomRule();
});
it('We show two primary callouts', () => {
waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary');
waitForCallOutToBeShown(RULES_CALLOUT, 'primary');
it('We show one primary callout', () => {
waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary');
});
context('When a user clicks Dismiss on the callouts', () => {
it('We hide them and persist the dismissal', () => {
waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary');
waitForCallOutToBeShown(RULES_CALLOUT, 'primary');
waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary');
dismissCallOut(ALERTS_CALLOUT);
dismissCallOut(MISSING_PRIVILEGES_CALLOUT);
reloadPage();
getCallOut(ALERTS_CALLOUT).should('not.exist');
getCallOut(RULES_CALLOUT).should('be.visible');
dismissCallOut(RULES_CALLOUT);
reloadPage();
getCallOut(ALERTS_CALLOUT).should('not.exist');
getCallOut(RULES_CALLOUT).should('not.exist');
getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist');
});
});
});
@ -139,8 +129,7 @@ describe('Detections > Callouts', () => {
});
it('We show no callout', () => {
getCallOut(ALERTS_CALLOUT).should('not.exist');
getCallOut(RULES_CALLOUT).should('not.exist');
getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist');
});
});
@ -150,8 +139,7 @@ describe('Detections > Callouts', () => {
});
it('We show no callout', () => {
getCallOut(ALERTS_CALLOUT).should('not.exist');
getCallOut(RULES_CALLOUT).should('not.exist');
getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist');
});
});
@ -168,8 +156,7 @@ describe('Detections > Callouts', () => {
});
it('We show no callouts', () => {
getCallOut(ALERTS_CALLOUT).should('not.exist');
getCallOut(RULES_CALLOUT).should('not.exist');
getCallOut(MISSING_PRIVILEGES_CALLOUT).should('not.exist');
});
});
});

View file

@ -7,6 +7,6 @@
export const CALLOUT = '[data-test-subj^="callout-"]';
export const callOutWithId = (id: string) => `[data-test-subj="callout-${id}"]`;
export const callOutWithId = (id: string) => `[data-test-subj^="callout-${id}"]`;
export const CALLOUT_DISMISS_BTN = '[data-test-subj^="callout-dismiss-"]';

View file

@ -25,6 +25,7 @@ import { ManageGlobalTimeline } from '../timelines/components/manage_timeline';
import { StartServices } from '../types';
import { PageRouter } from './routes';
import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common';
import { UserPrivilegesProvider } from '../detections/components/user_privileges';
interface StartAppComponent {
children: React.ReactNode;
@ -45,11 +46,13 @@ const StartAppComponent: FC<StartAppComponent> = ({ children, history, onAppLeav
<ReduxStoreProvider store={store}>
<EuiThemeProvider darkMode={darkMode}>
<MlCapabilitiesProvider>
<ManageUserInfo>
<PageRouter history={history} onAppLeave={onAppLeave}>
{children}
</PageRouter>
</ManageUserInfo>
<UserPrivilegesProvider>
<ManageUserInfo>
<PageRouter history={history} onAppLeave={onAppLeave}>
{children}
</PageRouter>
</ManageUserInfo>
</UserPrivilegesProvider>
</MlCapabilitiesProvider>
</EuiThemeProvider>
<ErrorToastDispatcher />

View file

@ -18,7 +18,7 @@ export const READ_ONLY_SAVED_OBJECT_MSG = i18n.translate(
'xpack.securitySolution.cases.readOnlySavedObjectDescription',
{
defaultMessage:
'You only have permissions to view cases. If you need to open and update cases, contact your Kibana administrator.',
'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.',
}
);

View file

@ -25,7 +25,7 @@ export const CasesPage = React.memo(() => {
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={savedObjectReadOnlyErrorMessage.title}
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
messages={[{ ...savedObjectReadOnlyErrorMessage, title: '' }]}
/>
)}
<AllCases userCanCrud={userPermissions?.crud ?? false} />

View file

@ -38,7 +38,7 @@ export const CaseDetailsPage = React.memo(() => {
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={savedObjectReadOnlyErrorMessage.title}
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
messages={[{ ...savedObjectReadOnlyErrorMessage, title: '' }]}
/>
)}
<CaseView

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import { difference, fromPairs, identity } from 'lodash/fp';
import { useCallback, useMemo } from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { difference, fromPairs, identity, intersection, isEqual } from 'lodash/fp';
import { useCallback, useEffect } from 'react';
import useMap from 'react-use/lib/useMap';
import { useMessagesStorage } from '../../containers/local_storage/use_messages_storage';
import { CallOutMessage } from './callout_types';
@ -24,8 +23,7 @@ export const useCallOutStorage = (
): CallOutStorage => {
const { getMessages, addMessage } = useMessagesStorage();
const visibilityStateInitial = useMemo(() => createInitialVisibilityState(messages), [messages]);
const [visibilityState, setVisibilityState] = useMap(visibilityStateInitial);
const [visibilityState, setVisibilityState] = useMap<Record<string, boolean>>({});
const dismissedMessagesKey = getDismissedMessagesStorageKey(namespace);
@ -58,16 +56,27 @@ export const useCallOutStorage = (
[setVisibilityState, addMessage, dismissedMessagesKey]
);
useEffectOnce(() => {
const idsAll = Object.keys(visibilityState);
const idsDismissed = getMessages(dismissedMessagesKey);
const idsToMakeVisible = difference(idsAll)(idsDismissed);
const populateVisibilityState = useCallback(
(ids: string[]) => {
const idsDismissed = getMessages(dismissedMessagesKey);
const idsToShow = difference(ids, idsDismissed);
const idsToHide = intersection(ids, idsDismissed);
setVisibilityState.setAll({
...createVisibilityState(idsToMakeVisible, true),
...createVisibilityState(idsDismissed, false),
});
});
setVisibilityState.setAll({
...createVisibilityState(idsToShow, true),
...createVisibilityState(idsToHide, false),
});
},
[getMessages, dismissedMessagesKey, setVisibilityState]
);
useEffect(() => {
const idsFromProps = messages.map((m) => m.id);
const idsFromState = Object.keys(visibilityState);
if (!isEqual(idsFromProps, idsFromState)) {
populateVisibilityState(idsFromProps);
}
}, [messages, visibilityState, populateVisibilityState]);
return {
getVisibleMessageIds,
@ -79,12 +88,6 @@ export const useCallOutStorage = (
const getDismissedMessagesStorageKey = (namespace: string) =>
`kibana.securitySolution.${namespace}.callouts.dismissed`;
const createInitialVisibilityState = (messages: CallOutMessage[]) =>
createVisibilityState(
messages.map((m) => m.id),
false
);
const createVisibilityState = (messageIds: string[], isVisible: boolean) =>
mapToObject(messageIds, identity, () => isVisible);

View file

@ -0,0 +1,24 @@
/*
* 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 { EuiCode } from '@elastic/eui';
import React from 'react';
interface CommaSeparatedValuesProps {
values: React.ReactNode[];
}
export const CommaSeparatedValues = ({ values }: CommaSeparatedValuesProps) => (
<>
{values.map((value, i) => (
<React.Fragment key={i}>
<EuiCode>{value}</EuiCode>
{i < values.length - 1 ? ', ' : ''}
</React.Fragment>
))}
</>
);

View file

@ -0,0 +1,48 @@
/*
* 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 React, { memo, useMemo } from 'react';
import hash from 'object-hash';
import { CallOutMessage, CallOutSwitcher } from '../../../../common/components/callouts';
import * as i18n from './translations';
import { useMissingPrivileges } from './use_missing_privileges';
const MissingPrivilegesCallOutComponent = () => {
const missingPrivileges = useMissingPrivileges();
const MissingPrivilegesMessage: CallOutMessage | null = useMemo(() => {
const hasMissingPrivileges =
missingPrivileges.indexPrivileges.length > 0 ||
missingPrivileges.featurePrivileges.length > 0;
if (!hasMissingPrivileges) {
return null;
}
const missingPrivilegesHash = hash(missingPrivileges);
return {
type: 'primary',
/**
* Use privileges hash as a part of the message id.
* We want to make sure that the user will see the
* callout message in case his privileges change.
* The previous click on Dismiss should not affect that.
*/
id: `missing-user-privileges-${missingPrivilegesHash}`,
title: i18n.MISSING_PRIVILEGES_CALLOUT_TITLE,
description: i18n.missingPrivilegesCallOutBody(missingPrivileges),
};
}, [missingPrivileges]);
return (
MissingPrivilegesMessage && (
<CallOutSwitcher namespace="detections" condition={true} message={MissingPrivilegesMessage} />
)
);
};
export const MissingPrivilegesCallOut = memo(MissingPrivilegesCallOutComponent);

View file

@ -0,0 +1,147 @@
/*
* 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 { EuiCode } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import {
DetectionsRequirementsLink,
SecuritySolutionRequirementsLink,
} from '../../../../common/components/links_to_docs';
import {
DEFAULT_ITEMS_INDEX,
DEFAULT_LISTS_INDEX,
DEFAULT_SIGNALS_INDEX,
SAVED_OBJECTS_MANAGEMENT_FEATURE_ID,
} from '../../../../../common/constants';
import { CommaSeparatedValues } from './comma_separated_values';
import { MissingPrivileges } from './use_missing_privileges';
export const MISSING_PRIVILEGES_CALLOUT_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageTitle',
{
defaultMessage: 'Insufficient privileges',
}
);
const CANNOT_EDIT_RULES = i18n.translate(
'xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.cannotEditRules',
{
defaultMessage: 'Without that privilege you cannot create or edit detection engine rules.',
}
);
const CANNOT_EDIT_LISTS = i18n.translate(
'xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.cannotEditLists',
{
defaultMessage: 'Without these privileges, you cannot create or edit value lists.',
}
);
const CANNOT_EDIT_ALERTS = i18n.translate(
'xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.cannotEditAlerts',
{
defaultMessage: 'Without these privileges, you cannot open or close alerts.',
}
);
export const missingPrivilegesCallOutBody = ({
indexPrivileges,
featurePrivileges,
}: MissingPrivileges) => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.messageDetail"
defaultMessage="{essence} Missing privileges: {privileges} Related documentation: {docs}"
values={{
essence: (
<p>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.essenceDescription"
defaultMessage="You need the following privileges to fully access this functionality. Contact your administrator for further assistance."
/>
</p>
),
privileges: (
<ul>
{indexPrivileges.map(([index, missingPrivileges]) => (
<li key={index}>{missingIndexPrivileges(index, missingPrivileges)}</li>
))}
{featurePrivileges.map(([feature, missingPrivileges]) => (
<li key={feature}>{missingFeaturePrivileges(feature, missingPrivileges)}</li>
))}
</ul>
),
docs: (
<ul>
<li>
<DetectionsRequirementsLink />
</li>
<li>
<SecuritySolutionRequirementsLink />
</li>
</ul>
),
}}
/>
);
interface PrivilegeExplanations {
[key: string]: {
[privilegeName: string]: string;
};
}
const PRIVILEGE_EXPLANATIONS: PrivilegeExplanations = {
[SAVED_OBJECTS_MANAGEMENT_FEATURE_ID]: {
all: CANNOT_EDIT_RULES,
},
[DEFAULT_SIGNALS_INDEX]: {
write: CANNOT_EDIT_ALERTS,
},
[DEFAULT_LISTS_INDEX]: {
write: CANNOT_EDIT_LISTS,
},
[DEFAULT_ITEMS_INDEX]: {
write: CANNOT_EDIT_LISTS,
},
};
const getPrivilegesExplanation = (missingPrivileges: string[], index: string) => {
const explanationsByPrivilege = Object.entries(PRIVILEGE_EXPLANATIONS).find(([key]) =>
index.startsWith(key)
)?.[1];
return missingPrivileges
.map((privilege) => explanationsByPrivilege?.[privilege])
.filter(Boolean)
.join(' ');
};
const missingIndexPrivileges = (index: string, privileges: string[]) => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.missingIndexPrivileges"
defaultMessage="Missing {privileges} privileges for the {index} index. {explanation}"
values={{
privileges: <CommaSeparatedValues values={privileges} />,
index: <EuiCode>{index}</EuiCode>,
explanation: getPrivilegesExplanation(privileges, index),
}}
/>
);
const missingFeaturePrivileges = (feature: string, privileges: string[]) => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.missingPrivilegesCallOut.messageBody.missingFeaturePrivileges"
defaultMessage="Missing {privileges} privileges for the {index} feature. {explanation}"
values={{
privileges: <CommaSeparatedValues values={privileges} />,
index: <EuiCode>{feature}</EuiCode>,
explanation: getPrivilegesExplanation(privileges, feature),
}}
/>
);

View file

@ -0,0 +1,91 @@
/*
* 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 { useMemo } from 'react';
import { SAVED_OBJECTS_MANAGEMENT_FEATURE_ID } from '../../../../../common/constants';
import { Privilege } from '../../../containers/detection_engine/alerts/types';
import { useUserData } from '../../user_info';
import { useUserPrivileges } from '../../user_privileges';
const REQUIRED_INDEX_PRIVILIGES = ['read', 'write', 'view_index_metadata', 'maintenance'] as const;
const getIndexName = (indexPrivileges: Privilege['index']) => {
const [indexName] = Object.keys(indexPrivileges);
return indexName;
};
const getMissingIndexPrivileges = (
indexPrivileges: Privilege['index']
): MissingIndexPrivileges | undefined => {
const indexName = getIndexName(indexPrivileges);
const privileges = indexPrivileges[indexName];
const missingPrivileges = REQUIRED_INDEX_PRIVILIGES.filter((privelege) => !privileges[privelege]);
if (missingPrivileges.length) {
return [indexName, missingPrivileges];
}
};
export type MissingFeaturePrivileges = [feature: string, privileges: string[]];
export type MissingIndexPrivileges = [indexName: string, privileges: string[]];
export interface MissingPrivileges {
featurePrivileges: MissingFeaturePrivileges[];
indexPrivileges: MissingIndexPrivileges[];
}
export const useMissingPrivileges = (): MissingPrivileges => {
const { detectionEnginePrivileges, listPrivileges } = useUserPrivileges();
const [{ canUserCRUD }] = useUserData();
return useMemo<MissingPrivileges>(() => {
const featurePrivileges: MissingFeaturePrivileges[] = [];
const indexPrivileges: MissingIndexPrivileges[] = [];
if (
canUserCRUD == null ||
listPrivileges.result == null ||
detectionEnginePrivileges.result == null
) {
/**
* Do not check privileges till we get all the data. That helps to reduce
* subsequent layout shift while loading and skip unneeded re-renders.
*/
return {
featurePrivileges,
indexPrivileges,
};
}
if (canUserCRUD === false) {
featurePrivileges.push([SAVED_OBJECTS_MANAGEMENT_FEATURE_ID, ['all']]);
}
const missingItemsPrivileges = getMissingIndexPrivileges(listPrivileges.result.listItems.index);
if (missingItemsPrivileges) {
indexPrivileges.push(missingItemsPrivileges);
}
const missingListsPrivileges = getMissingIndexPrivileges(listPrivileges.result.lists.index);
if (missingListsPrivileges) {
indexPrivileges.push(missingListsPrivileges);
}
const missingDetectionPrivileges = getMissingIndexPrivileges(
detectionEnginePrivileges.result.index
);
if (missingDetectionPrivileges) {
indexPrivileges.push(missingDetectionPrivileges);
}
return {
featurePrivileges,
indexPrivileges,
};
}, [canUserCRUD, listPrivileges, detectionEnginePrivileges]);
};

View file

@ -1,33 +0,0 @@
/*
* 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 React, { memo } from 'react';
import { CallOutMessage, CallOutSwitcher } from '../../../../common/components/callouts';
import { useUserData } from '../../user_info';
import * as i18n from './translations';
const readOnlyAccessToAlertsMessage: CallOutMessage = {
type: 'primary',
id: 'read-only-access-to-alerts',
title: i18n.READ_ONLY_ALERTS_CALLOUT_TITLE,
description: i18n.readOnlyAlertsCallOutBody(),
};
const ReadOnlyAlertsCallOutComponent = () => {
const [{ hasIndexUpdateDelete }] = useUserData();
return (
<CallOutSwitcher
namespace="detections"
condition={hasIndexUpdateDelete != null && !hasIndexUpdateDelete}
message={readOnlyAccessToAlertsMessage}
/>
);
};
export const ReadOnlyAlertsCallOut = memo(ReadOnlyAlertsCallOutComponent);

View file

@ -1,48 +0,0 @@
/*
* 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 React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
SecuritySolutionRequirementsLink,
DetectionsRequirementsLink,
} from '../../../../common/components/links_to_docs';
export const READ_ONLY_ALERTS_CALLOUT_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageTitle',
{
defaultMessage: 'You cannot change alert states',
}
);
export const readOnlyAlertsCallOutBody = () => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageBody.messageDetail"
defaultMessage="{essence} Related documentation: {docs}"
values={{
essence: (
<p>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageBody.essenceDescription"
defaultMessage="You only have permissions to view alerts. If you need to update alert states (open or close alerts), contact your Kibana administrator."
/>
</p>
),
docs: (
<ul>
<li>
<DetectionsRequirementsLink />
</li>
<li>
<SecuritySolutionRequirementsLink />
</li>
</ul>
),
}}
/>
);

View file

@ -1,33 +0,0 @@
/*
* 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 React, { memo } from 'react';
import { CallOutMessage, CallOutSwitcher } from '../../../../common/components/callouts';
import { useUserData } from '../../user_info';
import * as i18n from './translations';
const readOnlyAccessToRulesMessage: CallOutMessage = {
type: 'primary',
id: 'read-only-access-to-rules',
title: i18n.READ_ONLY_RULES_CALLOUT_TITLE,
description: i18n.readOnlyRulesCallOutBody(),
};
const ReadOnlyRulesCallOutComponent = () => {
const [{ canUserCRUD }] = useUserData();
return (
<CallOutSwitcher
namespace="detections"
condition={canUserCRUD != null && !canUserCRUD}
message={readOnlyAccessToRulesMessage}
/>
);
};
export const ReadOnlyRulesCallOut = memo(ReadOnlyRulesCallOutComponent);

View file

@ -1,48 +0,0 @@
/*
* 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 React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
SecuritySolutionRequirementsLink,
DetectionsRequirementsLink,
} from '../../../../common/components/links_to_docs';
export const READ_ONLY_RULES_CALLOUT_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.readOnlyRulesCallOut.messageTitle',
{
defaultMessage: 'Rule permissions required',
}
);
export const readOnlyRulesCallOutBody = () => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.readOnlyRulesCallOut.messageBody.messageDetail"
defaultMessage="{essence} Related documentation: {docs}"
values={{
essence: (
<p>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.readOnlyRulesCallOut.messageBody.essenceDescription"
defaultMessage="You are currently missing the required permissions to create/edit detection engine rule. Please contact your administrator for further assistance."
/>
</p>
),
docs: (
<ul>
<li>
<DetectionsRequirementsLink />
</li>
<li>
<SecuritySolutionRequirementsLink />
</li>
</ul>
),
}}
/>
);

View file

@ -5,13 +5,14 @@
* 2.0.
*/
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useUserInfo, ManageUserInfo } from './index';
import { useKibana } from '../../../common/lib/kibana';
import * as api from '../../containers/detection_engine/alerts/api';
import { TestProviders } from '../../../common/mock/test_providers';
import React from 'react';
import { UserPrivilegesProvider } from '../user_privileges';
jest.mock('../../../common/lib/kibana');
jest.mock('../../containers/detection_engine/alerts/api');
@ -63,7 +64,9 @@ describe('useUserInfo', () => {
});
const wrapper = ({ children }: { children: JSX.Element }) => (
<TestProviders>
<ManageUserInfo>{children}</ManageUserInfo>
<UserPrivilegesProvider>
<ManageUserInfo>{children}</ManageUserInfo>
</UserPrivilegesProvider>
</TestProviders>
);
await act(async () => {

View file

@ -8,7 +8,7 @@
import { noop } from 'lodash/fp';
import React, { useEffect, useReducer, Dispatch, createContext, useContext } from 'react';
import { usePrivilegeUser } from '../../containers/detection_engine/alerts/use_privilege_user';
import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges';
import { useSignalIndex } from '../../containers/detection_engine/alerts/use_signal_index';
import { useKibana } from '../../../common/lib/kibana';
import { useCreateTransforms } from '../../../transforms/containers/use_create_transforms';
@ -196,7 +196,7 @@ export const useUserInfo = (): State => {
hasIndexMaintenance: hasApiIndexMaintenance,
hasIndexWrite: hasApiIndexWrite,
hasIndexUpdateDelete: hasApiIndexUpdateDelete,
} = usePrivilegeUser();
} = useAlertsPrivileges();
const {
loading: indexNameLoading,
signalIndexExists: isApiSignalIndexExists,
@ -208,8 +208,7 @@ export const useUserInfo = (): State => {
const { createTransforms } = useCreateTransforms();
const uiCapabilities = useKibana().services.application.capabilities;
const capabilitiesCanUserCRUD: boolean =
typeof uiCapabilities.siem.crud === 'boolean' ? uiCapabilities.siem.crud : false;
const capabilitiesCanUserCRUD: boolean = uiCapabilities.siem.crud === true;
useEffect(() => {
if (loading !== (privilegeLoading || indexNameLoading)) {
@ -275,7 +274,7 @@ export const useUserInfo = (): State => {
}, [dispatch, loading, hasEncryptionKey, isApiEncryptionKey]);
useEffect(() => {
if (!loading && canUserCRUD !== capabilitiesCanUserCRUD && capabilitiesCanUserCRUD != null) {
if (!loading && canUserCRUD !== capabilitiesCanUserCRUD) {
dispatch({ type: 'updateCanUserCRUD', canUserCRUD: capabilitiesCanUserCRUD });
}
}, [dispatch, loading, canUserCRUD, capabilitiesCanUserCRUD]);

View file

@ -0,0 +1,42 @@
/*
* 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 React, { createContext, useContext } from 'react';
import { useFetchDetectionEnginePrivileges } from './use_fetch_detection_engine_privileges';
import { useFetchListPrivileges } from './use_fetch_list_privileges';
export interface UserPrivilegesState {
listPrivileges: ReturnType<typeof useFetchListPrivileges>;
detectionEnginePrivileges: ReturnType<typeof useFetchDetectionEnginePrivileges>;
}
const UserPrivilegesContext = createContext<UserPrivilegesState>({
listPrivileges: { loading: false, error: undefined, result: undefined },
detectionEnginePrivileges: { loading: false, error: undefined, result: undefined },
});
interface UserPrivilegesProviderProps {
children: React.ReactNode;
}
export const UserPrivilegesProvider = ({ children }: UserPrivilegesProviderProps) => {
const listPrivileges = useFetchListPrivileges();
const detectionEnginePrivileges = useFetchDetectionEnginePrivileges();
return (
<UserPrivilegesContext.Provider
value={{
listPrivileges,
detectionEnginePrivileges,
}}
>
{children}
</UserPrivilegesContext.Provider>
);
};
export const useUserPrivileges = () => useContext(UserPrivilegesContext);

View file

@ -0,0 +1,22 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const LISTS_PRIVILEGES_FETCH_FAILURE = i18n.translate(
'xpack.securitySolution.containers.detectionEngine.alerts.readListsPrivileges.errorDescription',
{
defaultMessage: 'Failed to retrieve lists privileges',
}
);
export const DETECTION_ENGINE_PRIVILEGES_FETCH_FAILURE = i18n.translate(
'xpack.securitySolution.containers.detectionEngine.alerts.detectionEnginePrivileges.errorFetching',
{
defaultMessage: 'Failed to retreive detection engine privileges',
}
);

View file

@ -0,0 +1,12 @@
/*
* 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 { useFetchDetectionEnginePrivileges } from './use_fetch_detection_engine_privileges';
export const useFetchDetectionEnginePrivilegesMock: () => jest.Mocked<
ReturnType<typeof useFetchDetectionEnginePrivileges>
> = () => ({ loading: false, error: undefined, result: undefined });

View file

@ -0,0 +1,47 @@
/*
* 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 { useEffect, useRef } from 'react';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import { useAsync, withOptionalSignal } from '../../../shared_imports';
import { getUserPrivilege } from '../../containers/detection_engine/alerts/api';
import * as i18n from './translations';
export const useFetchPrivileges = () => useAsync(withOptionalSignal(getUserPrivilege));
export const useFetchDetectionEnginePrivileges = () => {
const { start, ...detectionEnginePrivileges } = useFetchPrivileges();
const { addError } = useAppToasts();
const abortCtrlRef = useRef(new AbortController());
useEffect(() => {
const { loading, result, error } = detectionEnginePrivileges;
if (!loading && !(result || error)) {
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
start({ signal: abortCtrlRef.current.signal });
}
}, [start, detectionEnginePrivileges]);
useEffect(() => {
return () => {
abortCtrlRef.current.abort();
};
}, []);
useEffect(() => {
const error = detectionEnginePrivileges.error;
if (error != null) {
addError(error, {
title: i18n.DETECTION_ENGINE_PRIVILEGES_FETCH_FAILURE,
});
}
}, [addError, detectionEnginePrivileges.error]);
return detectionEnginePrivileges;
};

View file

@ -0,0 +1,62 @@
/*
* 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 { useEffect, useRef } from 'react';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import { useHttp, useKibana } from '../../../common/lib/kibana';
import { useReadListPrivileges } from '../../../shared_imports';
import { Privilege } from '../../containers/detection_engine/alerts/types';
import * as i18n from './translations';
interface ListPrivileges {
is_authenticated: boolean;
lists: {
index: Privilege['index'];
};
listItems: {
index: Privilege['index'];
};
}
export const useFetchListPrivileges = () => {
const http = useHttp();
const { lists } = useKibana().services;
const { start: fetchListPrivileges, ...listPrivileges } = useReadListPrivileges();
const { addError } = useAppToasts();
const abortCtrlRef = useRef(new AbortController());
useEffect(() => {
const { loading, result, error } = listPrivileges;
if (lists && !loading && !(result || error)) {
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
fetchListPrivileges({ http, signal: abortCtrlRef.current.signal });
}
}, [http, lists, fetchListPrivileges, listPrivileges]);
useEffect(() => {
return () => {
abortCtrlRef.current.abort();
};
}, []);
useEffect(() => {
const error = listPrivileges.error;
if (error != null) {
addError(error, {
title: i18n.LISTS_PRIVILEGES_FETCH_FAILURE,
});
}
}, [addError, listPrivileges.error]);
return {
loading: listPrivileges.loading,
error: listPrivileges.error,
result: listPrivileges.result as ListPrivileges | undefined,
};
};

View file

@ -14,13 +14,6 @@ export const ALERT_FETCH_FAILURE = i18n.translate(
}
);
export const PRIVILEGE_FETCH_FAILURE = i18n.translate(
'xpack.securitySolution.containers.detectionEngine.alerts.errorFetchingAlertsDescription',
{
defaultMessage: 'Failed to query alerts',
}
);
export const SIGNAL_GET_NAME_FAILURE = i18n.translate(
'xpack.securitySolution.containers.detectionEngine.alerts.errorGetAlertDescription',
{

View file

@ -0,0 +1,196 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import produce from 'immer';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock';
import { useUserPrivileges } from '../../../components/user_privileges';
import { Privilege } from './types';
import { UseAlertsPrivelegesReturn, useAlertsPrivileges } from './use_alerts_privileges';
jest.mock('./api');
jest.mock('../../../../common/hooks/use_app_toasts');
jest.mock('../../../components/user_privileges');
const useUserPrivilegesMock = useUserPrivileges as jest.Mock<ReturnType<typeof useUserPrivileges>>;
const privilege: Privilege = {
username: 'soc_manager',
has_all_requested: false,
cluster: {
monitor_ml: false,
manage_ccr: false,
manage_index_templates: false,
monitor_watcher: false,
monitor_transform: false,
read_ilm: false,
manage_api_key: false,
manage_security: false,
manage_own_api_key: false,
manage_saml: false,
all: false,
manage_ilm: false,
manage_ingest_pipelines: false,
read_ccr: false,
manage_rollup: false,
monitor: false,
manage_watcher: false,
manage: true,
manage_transform: false,
manage_token: false,
manage_ml: false,
manage_pipeline: false,
monitor_rollup: false,
transport_client: false,
create_snapshot: false,
},
index: {
'.siem-signals-default': {
all: false,
manage_ilm: true,
read: true,
create_index: true,
read_cross_cluster: false,
index: true,
monitor: true,
delete: true,
manage: true,
delete_index: true,
create_doc: true,
view_index_metadata: true,
create: true,
manage_follow_index: true,
manage_leader_index: true,
maintenance: true,
write: true,
},
},
application: {},
is_authenticated: true,
has_encryption_key: true,
};
const userPrivilegesInitial: ReturnType<typeof useUserPrivileges> = {
detectionEnginePrivileges: {
loading: false,
result: undefined,
error: undefined,
},
listPrivileges: {
loading: false,
result: undefined,
error: undefined,
},
};
describe('usePrivilegeUser', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.resetAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
useUserPrivilegesMock.mockReturnValue(userPrivilegesInitial);
});
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, UseAlertsPrivelegesReturn>(() =>
useAlertsPrivileges()
);
await waitForNextUpdate();
expect(result.current).toEqual({
hasEncryptionKey: null,
hasIndexManage: null,
hasIndexRead: null,
hasIndexMaintenance: null,
hasIndexWrite: null,
hasIndexUpdateDelete: null,
isAuthenticated: null,
loading: false,
});
});
});
test('if there is an error when fetching user privilege, we should get back false for every properties', async () => {
const userPrivileges = produce(userPrivilegesInitial, (draft) => {
draft.detectionEnginePrivileges.error = new Error('Something went wrong');
});
useUserPrivilegesMock.mockReturnValue(userPrivileges);
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, UseAlertsPrivelegesReturn>(() =>
useAlertsPrivileges()
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
hasEncryptionKey: false,
hasIndexManage: false,
hasIndexMaintenance: false,
hasIndexRead: false,
hasIndexWrite: false,
hasIndexUpdateDelete: false,
isAuthenticated: false,
loading: false,
});
});
});
test('returns "hasIndexManage" is false if the privilege does not have cluster manage', async () => {
const privilegeWithClusterManage = produce(privilege, (draft) => {
draft.cluster.manage = false;
});
const userPrivileges = produce(userPrivilegesInitial, (draft) => {
draft.detectionEnginePrivileges.result = privilegeWithClusterManage;
});
useUserPrivilegesMock.mockReturnValue(userPrivileges);
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, UseAlertsPrivelegesReturn>(() =>
useAlertsPrivileges()
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
hasEncryptionKey: true,
hasIndexManage: false,
hasIndexMaintenance: true,
hasIndexRead: true,
hasIndexWrite: true,
hasIndexUpdateDelete: true,
isAuthenticated: true,
loading: false,
});
});
});
test('returns "hasIndexManage" is true if the privilege has cluster manage', async () => {
const userPrivileges = produce(userPrivilegesInitial, (draft) => {
draft.detectionEnginePrivileges.result = privilege;
});
useUserPrivilegesMock.mockReturnValue(userPrivileges);
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, UseAlertsPrivelegesReturn>(() =>
useAlertsPrivileges()
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
hasEncryptionKey: true,
hasIndexManage: true,
hasIndexMaintenance: true,
hasIndexRead: true,
hasIndexWrite: true,
hasIndexUpdateDelete: true,
isAuthenticated: true,
loading: false,
});
});
});
});

View file

@ -0,0 +1,78 @@
/*
* 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 { useEffect, useState } from 'react';
import { useUserPrivileges } from '../../../components/user_privileges';
export interface UseAlertsPrivelegesReturn extends AlertsPrivelegesState {
loading: boolean;
}
export interface AlertsPrivelegesState {
isAuthenticated: boolean | null;
hasEncryptionKey: boolean | null;
hasIndexManage: boolean | null;
hasIndexWrite: boolean | null;
hasIndexUpdateDelete: boolean | null;
hasIndexMaintenance: boolean | null;
hasIndexRead: boolean | null;
}
/**
* Hook to get user privilege from
*
*/
export const useAlertsPrivileges = (): UseAlertsPrivelegesReturn => {
const [privileges, setPrivileges] = useState<AlertsPrivelegesState>({
isAuthenticated: null,
hasEncryptionKey: null,
hasIndexManage: null,
hasIndexRead: null,
hasIndexWrite: null,
hasIndexUpdateDelete: null,
hasIndexMaintenance: null,
});
const { detectionEnginePrivileges } = useUserPrivileges();
useEffect(() => {
if (detectionEnginePrivileges.error != null) {
setPrivileges({
isAuthenticated: false,
hasEncryptionKey: false,
hasIndexManage: false,
hasIndexRead: false,
hasIndexWrite: false,
hasIndexUpdateDelete: false,
hasIndexMaintenance: false,
});
}
}, [detectionEnginePrivileges.error]);
useEffect(() => {
if (detectionEnginePrivileges.result != null) {
const privilege = detectionEnginePrivileges.result;
if (privilege.index != null && Object.keys(privilege.index).length > 0) {
const indexName = Object.keys(privilege.index)[0];
setPrivileges({
isAuthenticated: privilege.is_authenticated,
hasEncryptionKey: privilege.has_encryption_key,
hasIndexManage: privilege.index[indexName].manage && privilege.cluster.manage,
hasIndexMaintenance: privilege.index[indexName].maintenance,
hasIndexRead: privilege.index[indexName].read,
hasIndexWrite:
privilege.index[indexName].create ||
privilege.index[indexName].create_doc ||
privilege.index[indexName].index ||
privilege.index[indexName].write,
hasIndexUpdateDelete: privilege.index[indexName].write,
});
}
}
}, [detectionEnginePrivileges.result]);
return { loading: detectionEnginePrivileges.loading, ...privileges };
};

View file

@ -1,238 +0,0 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import { usePrivilegeUser, ReturnPrivilegeUser } from './use_privilege_user';
import * as api from './api';
import { Privilege } from './types';
import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
jest.mock('./api');
jest.mock('../../../../common/hooks/use_app_toasts');
describe('usePrivilegeUser', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.resetAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
});
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnPrivilegeUser>(() =>
usePrivilegeUser()
);
await waitForNextUpdate();
expect(result.current).toEqual({
hasEncryptionKey: null,
hasIndexManage: null,
hasIndexMaintenance: null,
hasIndexWrite: null,
hasIndexUpdateDelete: null,
isAuthenticated: null,
loading: true,
});
});
});
test('fetch user privilege', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnPrivilegeUser>(() =>
usePrivilegeUser()
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
hasEncryptionKey: true,
hasIndexManage: true,
hasIndexMaintenance: true,
hasIndexWrite: true,
hasIndexUpdateDelete: true,
isAuthenticated: true,
loading: false,
});
});
});
test('if there is an error when fetching user privilege, we should get back false for every properties', async () => {
const spyOnGetUserPrivilege = jest.spyOn(api, 'getUserPrivilege');
spyOnGetUserPrivilege.mockImplementation(() => {
throw new Error('Something went wrong, let see what happen');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnPrivilegeUser>(() =>
usePrivilegeUser()
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
hasEncryptionKey: false,
hasIndexManage: false,
hasIndexMaintenance: false,
hasIndexWrite: false,
hasIndexUpdateDelete: false,
isAuthenticated: false,
loading: false,
});
});
});
test('returns "hasIndexManage" is false if the privilege does not have cluster manage', async () => {
const privilege: Privilege = {
username: 'soc_manager',
has_all_requested: false,
cluster: {
monitor_ml: false,
manage_ccr: false,
manage_index_templates: false,
monitor_watcher: false,
monitor_transform: false,
read_ilm: false,
manage_api_key: false,
manage_security: false,
manage_own_api_key: false,
manage_saml: false,
all: false,
manage_ilm: false,
manage_ingest_pipelines: false,
read_ccr: false,
manage_rollup: false,
monitor: false,
manage_watcher: false,
manage: false,
manage_transform: false,
manage_token: false,
manage_ml: false,
manage_pipeline: false,
monitor_rollup: false,
transport_client: false,
create_snapshot: false,
},
index: {
'.siem-signals-default': {
all: false,
manage_ilm: true,
read: true,
create_index: true,
read_cross_cluster: false,
index: true,
monitor: true,
delete: true,
manage: true,
delete_index: true,
create_doc: true,
view_index_metadata: true,
create: true,
manage_follow_index: true,
manage_leader_index: true,
maintenance: true,
write: true,
},
},
application: {},
is_authenticated: true,
has_encryption_key: true,
};
const spyOnGetUserPrivilege = jest.spyOn(api, 'getUserPrivilege');
spyOnGetUserPrivilege.mockImplementation(() => Promise.resolve(privilege));
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnPrivilegeUser>(() =>
usePrivilegeUser()
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
hasEncryptionKey: true,
hasIndexManage: false,
hasIndexMaintenance: true,
hasIndexWrite: true,
hasIndexUpdateDelete: true,
isAuthenticated: true,
loading: false,
});
});
});
test('returns "hasIndexManage" is true if the privilege has cluster manage', async () => {
const privilege: Privilege = {
username: 'soc_manager',
has_all_requested: false,
cluster: {
monitor_ml: false,
manage_ccr: false,
manage_index_templates: false,
monitor_watcher: false,
monitor_transform: false,
read_ilm: false,
manage_api_key: false,
manage_security: false,
manage_own_api_key: false,
manage_saml: false,
all: false,
manage_ilm: false,
manage_ingest_pipelines: false,
read_ccr: false,
manage_rollup: false,
monitor: false,
manage_watcher: false,
manage: true,
manage_transform: false,
manage_token: false,
manage_ml: false,
manage_pipeline: false,
monitor_rollup: false,
transport_client: false,
create_snapshot: false,
},
index: {
'.siem-signals-default': {
all: false,
manage_ilm: true,
read: true,
create_index: true,
read_cross_cluster: false,
index: true,
monitor: true,
delete: true,
manage: true,
delete_index: true,
create_doc: true,
view_index_metadata: true,
create: true,
manage_follow_index: true,
manage_leader_index: true,
maintenance: true,
write: true,
},
},
application: {},
is_authenticated: true,
has_encryption_key: true,
};
const spyOnGetUserPrivilege = jest.spyOn(api, 'getUserPrivilege');
spyOnGetUserPrivilege.mockImplementation(() => Promise.resolve(privilege));
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnPrivilegeUser>(() =>
usePrivilegeUser()
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
hasEncryptionKey: true,
hasIndexManage: true,
hasIndexMaintenance: true,
hasIndexWrite: true,
hasIndexUpdateDelete: true,
isAuthenticated: true,
loading: false,
});
});
});
});

View file

@ -1,103 +0,0 @@
/*
* 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 { useEffect, useState } from 'react';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { getUserPrivilege } from './api';
import * as i18n from './translations';
export interface ReturnPrivilegeUser {
loading: boolean;
isAuthenticated: boolean | null;
hasEncryptionKey: boolean | null;
hasIndexManage: boolean | null;
hasIndexWrite: boolean | null;
hasIndexUpdateDelete: boolean | null;
hasIndexMaintenance: boolean | null;
}
/**
* Hook to get user privilege from
*
*/
export const usePrivilegeUser = (): ReturnPrivilegeUser => {
const [loading, setLoading] = useState(true);
const [privilegeUser, setPrivilegeUser] = useState<
Pick<
ReturnPrivilegeUser,
| 'isAuthenticated'
| 'hasEncryptionKey'
| 'hasIndexManage'
| 'hasIndexWrite'
| 'hasIndexUpdateDelete'
| 'hasIndexMaintenance'
>
>({
isAuthenticated: null,
hasEncryptionKey: null,
hasIndexManage: null,
hasIndexWrite: null,
hasIndexUpdateDelete: null,
hasIndexMaintenance: null,
});
const { addError } = useAppToasts();
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
setLoading(true);
const fetchData = async () => {
try {
const privilege = await getUserPrivilege({
signal: abortCtrl.signal,
});
if (isSubscribed && privilege != null) {
if (privilege.index != null && Object.keys(privilege.index).length > 0) {
const indexName = Object.keys(privilege.index)[0];
setPrivilegeUser({
isAuthenticated: privilege.is_authenticated,
hasEncryptionKey: privilege.has_encryption_key,
hasIndexManage: privilege.index[indexName].manage && privilege.cluster.manage,
hasIndexMaintenance: privilege.index[indexName].maintenance,
hasIndexWrite:
privilege.index[indexName].create ||
privilege.index[indexName].create_doc ||
privilege.index[indexName].index ||
privilege.index[indexName].write,
hasIndexUpdateDelete: privilege.index[indexName].write,
});
}
}
} catch (error) {
if (isSubscribed) {
setPrivilegeUser({
isAuthenticated: false,
hasEncryptionKey: false,
hasIndexManage: false,
hasIndexWrite: false,
hasIndexUpdateDelete: false,
hasIndexMaintenance: false,
});
addError(error, { title: i18n.PRIVILEGE_FETCH_FAILURE });
}
}
if (isSubscribed) {
setLoading(false);
}
};
fetchData();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [addError]);
return { loading, ...privilegeUser };
};

View file

@ -4,16 +4,21 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useSignalIndex, ReturnSignalIndex } from './use_signal_index';
import * as api from './api';
import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { UserPrivilegesProvider } from '../../../components/user_privileges';
jest.mock('./api');
jest.mock('../../../../common/hooks/use_app_toasts');
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<UserPrivilegesProvider>{children}</UserPrivilegesProvider>
);
describe('useSignalIndex', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
@ -26,8 +31,9 @@ describe('useSignalIndex', () => {
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(() =>
useSignalIndex()
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(
() => useSignalIndex(),
{ wrapper: Wrapper }
);
await waitForNextUpdate();
expect(result.current).toEqual({
@ -42,11 +48,13 @@ describe('useSignalIndex', () => {
test('fetch alerts info', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(() =>
useSignalIndex()
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(
() => useSignalIndex(),
{ wrapper: Wrapper }
);
await waitForNextUpdate();
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
createDeSignalIndex: result.current.createDeSignalIndex,
loading: false,
@ -59,11 +67,13 @@ describe('useSignalIndex', () => {
test('make sure that createSignalIndex is giving back the signal info', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(() =>
useSignalIndex()
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(
() => useSignalIndex(),
{ wrapper: Wrapper }
);
await waitForNextUpdate();
await waitForNextUpdate();
await waitForNextUpdate();
if (result.current.createDeSignalIndex != null) {
await result.current.createDeSignalIndex();
}
@ -81,11 +91,13 @@ describe('useSignalIndex', () => {
test('make sure that createSignalIndex have been called when trying to create signal index', async () => {
const spyOnCreateSignalIndex = jest.spyOn(api, 'createSignalIndex');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(() =>
useSignalIndex()
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(
() => useSignalIndex(),
{ wrapper: Wrapper }
);
await waitForNextUpdate();
await waitForNextUpdate();
await waitForNextUpdate();
if (result.current.createDeSignalIndex != null) {
await result.current.createDeSignalIndex();
}
@ -100,11 +112,13 @@ describe('useSignalIndex', () => {
throw new Error('Something went wrong, let see what happen');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(() =>
useSignalIndex()
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(
() => useSignalIndex(),
{ wrapper: Wrapper }
);
await waitForNextUpdate();
await waitForNextUpdate();
await waitForNextUpdate();
if (result.current.createDeSignalIndex != null) {
await result.current.createDeSignalIndex();
}
@ -124,11 +138,13 @@ describe('useSignalIndex', () => {
throw new Error('Something went wrong, let see what happen');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(() =>
useSignalIndex()
const { result, waitForNextUpdate } = renderHook<void, ReturnSignalIndex>(
() => useSignalIndex(),
{ wrapper: Wrapper }
);
await waitForNextUpdate();
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
createDeSignalIndex: result.current.createDeSignalIndex,
loading: false,

View file

@ -11,6 +11,7 @@ import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { createSignalIndex, getSignalIndex } from './api';
import * as i18n from './translations';
import { isSecurityAppError } from '../../../../common/utils/api';
import { useAlertsPrivileges } from './use_alerts_privileges';
type Func = () => Promise<void>;
@ -36,6 +37,7 @@ export const useSignalIndex = (): ReturnSignalIndex => {
createDeSignalIndex: null,
});
const { addError } = useAppToasts();
const { hasIndexRead } = useAlertsPrivileges();
useEffect(() => {
let isSubscribed = true;
@ -102,12 +104,18 @@ export const useSignalIndex = (): ReturnSignalIndex => {
}
};
fetchData();
if (hasIndexRead) {
fetchData();
} else {
// Skip data fetching as the current user doesn't have enough priviliges.
// Attempt to get the signal index will result in 500 error.
setLoading(false);
}
return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [addError]);
}, [addError, hasIndexRead]);
return { loading, ...signalIndex };
};

View file

@ -20,10 +20,3 @@ export const LISTS_INDEX_CREATE_FAILURE = i18n.translate(
defaultMessage: 'Failed to create the lists index',
}
);
export const LISTS_PRIVILEGES_READ_FAILURE = i18n.translate(
'xpack.securitySolution.containers.detectionEngine.alerts.readListsPrivileges.errorDescription',
{
defaultMessage: 'Failed to retrieve lists privileges',
}
);

View file

@ -12,6 +12,7 @@ import { useHttp, useKibana } from '../../../../common/lib/kibana';
import { isSecurityAppError } from '../../../../common/utils/api';
import * as i18n from './translations';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { useListsPrivileges } from './use_lists_privileges';
export interface UseListsIndexReturn {
createIndex: () => void;
@ -26,6 +27,7 @@ export const useListsIndex = (): UseListsIndexReturn => {
const { lists } = useKibana().services;
const http = useHttp();
const { addError } = useAppToasts();
const { canReadIndex } = useListsPrivileges();
const { loading: readLoading, start: readListIndex, ...readListIndexState } = useReadListIndex();
const {
loading: createLoading,
@ -35,10 +37,10 @@ export const useListsIndex = (): UseListsIndexReturn => {
const loading = readLoading || createLoading;
const readIndex = useCallback(() => {
if (lists) {
if (lists && canReadIndex) {
readListIndex({ http });
}
}, [http, lists, readListIndex]);
}, [http, lists, readListIndex, canReadIndex]);
const createIndex = useCallback(() => {
if (lists) {

View file

@ -9,6 +9,7 @@ import { UseListsPrivilegesReturn } from './use_lists_privileges';
export const getUseListsPrivilegesMock: () => jest.Mocked<UseListsPrivilegesReturn> = () => ({
isAuthenticated: null,
canReadIndex: null,
canManageIndex: null,
canWriteIndex: null,
loading: false,

View file

@ -5,16 +5,14 @@
* 2.0.
*/
import { useEffect, useState, useCallback } from 'react';
import { useReadListPrivileges } from '../../../../shared_imports';
import { useHttp, useKibana } from '../../../../common/lib/kibana';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import * as i18n from './translations';
import { useEffect, useState } from 'react';
import { useUserPrivileges } from '../../../components/user_privileges';
import { Privilege } from '../alerts/types';
export interface UseListsPrivilegesState {
isAuthenticated: boolean | null;
canManageIndex: boolean | null;
canReadIndex: boolean | null;
canWriteIndex: boolean | null;
}
@ -22,38 +20,7 @@ export interface UseListsPrivilegesReturn extends UseListsPrivilegesState {
loading: boolean;
}
interface ListIndexPrivileges {
[indexName: string]: {
all: boolean;
create: boolean;
create_doc: boolean;
create_index: boolean;
delete: boolean;
delete_index: boolean;
index: boolean;
manage: boolean;
manage_follow_index: boolean;
manage_ilm: boolean;
manage_leader_index: boolean;
monitor: boolean;
read: boolean;
read_cross_cluster: boolean;
view_index_metadata: boolean;
write: boolean;
};
}
interface ListPrivileges {
is_authenticated: boolean;
lists: {
index: ListIndexPrivileges;
};
listItems: {
index: ListIndexPrivileges;
};
}
const canManageIndex = (indexPrivileges: ListIndexPrivileges): boolean => {
const canManageIndex = (indexPrivileges: Privilege['index']): boolean => {
const [indexName] = Object.keys(indexPrivileges);
const privileges = indexPrivileges[indexName];
if (privileges == null) {
@ -62,7 +29,17 @@ const canManageIndex = (indexPrivileges: ListIndexPrivileges): boolean => {
return privileges.manage;
};
const canWriteIndex = (indexPrivileges: ListIndexPrivileges): boolean => {
const canReadIndex = (indexPrivileges: Privilege['index']): boolean => {
const [indexName] = Object.keys(indexPrivileges);
const privileges = indexPrivileges[indexName];
if (privileges == null) {
return false;
}
return privileges.read;
};
const canWriteIndex = (indexPrivileges: Privilege['index']): boolean => {
const [indexName] = Object.keys(indexPrivileges);
const privileges = indexPrivileges[indexName];
if (privileges == null) {
@ -76,57 +53,41 @@ export const useListsPrivileges = (): UseListsPrivilegesReturn => {
const [state, setState] = useState<UseListsPrivilegesState>({
isAuthenticated: null,
canManageIndex: null,
canReadIndex: null,
canWriteIndex: null,
});
const { lists } = useKibana().services;
const http = useHttp();
const { addError } = useAppToasts();
const { loading, start: readListPrivileges, ...readState } = useReadListPrivileges();
const readPrivileges = useCallback(() => {
if (lists) {
readListPrivileges({ http });
}
}, [http, lists, readListPrivileges]);
// initRead
useEffect(() => {
if (!loading && !readState.error && state.isAuthenticated === null) {
readPrivileges();
}
}, [loading, readState.error, readPrivileges, state.isAuthenticated]);
const { listPrivileges } = useUserPrivileges();
// handleReadResult
useEffect(() => {
if (readState.result != null) {
try {
const {
is_authenticated: isAuthenticated,
lists: { index: listsPrivileges },
listItems: { index: listItemsPrivileges },
} = readState.result as ListPrivileges;
if (listPrivileges.result != null) {
const {
is_authenticated: isAuthenticated,
lists: { index: listsPrivileges },
listItems: { index: listItemsPrivileges },
} = listPrivileges.result;
setState({
isAuthenticated,
canManageIndex: canManageIndex(listsPrivileges) && canManageIndex(listItemsPrivileges),
canWriteIndex: canWriteIndex(listsPrivileges) && canWriteIndex(listItemsPrivileges),
});
} catch (e) {
setState({ isAuthenticated: null, canManageIndex: false, canWriteIndex: false });
}
setState({
isAuthenticated,
canReadIndex: canReadIndex(listsPrivileges) && canReadIndex(listItemsPrivileges),
canManageIndex: canManageIndex(listsPrivileges) && canManageIndex(listItemsPrivileges),
canWriteIndex: canWriteIndex(listsPrivileges) && canWriteIndex(listItemsPrivileges),
});
}
}, [readState.result]);
}, [listPrivileges.result]);
// handleReadError
useEffect(() => {
const error = readState.error;
if (error != null) {
setState({ isAuthenticated: false, canManageIndex: false, canWriteIndex: false });
addError(error, {
title: i18n.LISTS_PRIVILEGES_READ_FAILURE,
if (listPrivileges.error != null) {
setState({
isAuthenticated: false,
canManageIndex: false,
canReadIndex: false,
canWriteIndex: false,
});
}
}, [addError, readState.error]);
}, [listPrivileges.error]);
return { loading, ...state };
return { loading: listPrivileges.loading, ...state };
};

View file

@ -28,7 +28,6 @@ import { SpyRoute } from '../../../common/utils/route/spy_routes';
import { useAlertInfo } from '../../components/alerts_info';
import { AlertsTable } from '../../components/alerts_table';
import { NoApiIntegrationKeyCallOut } from '../../components/callouts/no_api_integration_callout';
import { ReadOnlyAlertsCallOut } from '../../components/callouts/read_only_alerts_callout';
import { AlertsHistogramPanel } from '../../components/alerts_histogram_panel';
import { alertsHistogramOptions } from '../../components/alerts_histogram_panel/config';
import { useUserData } from '../../components/user_info';
@ -57,6 +56,7 @@ import {
import { useSourcererScope } from '../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { NeedAdminForUpdateRulesCallOut } from '../../components/callouts/need_admin_for_update_callout';
import { MissingPrivilegesCallOut } from '../../components/callouts/missing_privileges_callout';
/**
* Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space.
@ -211,8 +211,8 @@ const DetectionEnginePageComponent = () => {
return (
<>
{hasEncryptionKey != null && !hasEncryptionKey && <NoApiIntegrationKeyCallOut />}
<ReadOnlyAlertsCallOut />
<NeedAdminForUpdateRulesCallOut />
<MissingPrivilegesCallOut />
{indicesExist ? (
<StyledFullHeightContainer onKeyDown={onKeyDown} ref={containerElement}>
<EuiWindowEvent event="resize" handler={noop} />

View file

@ -61,8 +61,6 @@ import {
buildShowBuildingBlockFilter,
buildThreatMatchFilter,
} from '../../../../components/alerts_table/default_config';
import { ReadOnlyAlertsCallOut } from '../../../../components/callouts/read_only_alerts_callout';
import { ReadOnlyRulesCallOut } from '../../../../components/callouts/read_only_rules_callout';
import { RuleSwitch } from '../../../../components/rules/rule_switch';
import { StepPanel } from '../../../../components/rules/step_panel';
import { getStepsData, redirectToDetections, userHasNoPermissions } from '../helpers';
@ -109,6 +107,7 @@ import * as i18n from './translations';
import { isTab } from '../../../../../common/components/accessibility/helpers';
import { NeedAdminForUpdateRulesCallOut } from '../../../../components/callouts/need_admin_for_update_callout';
import { getRuleStatusText } from '../../../../../../common/detection_engine/utils';
import { MissingPrivilegesCallOut } from '../../../../components/callouts/missing_privileges_callout';
/**
* Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space.
@ -528,8 +527,7 @@ const RuleDetailsPageComponent = () => {
return (
<>
<NeedAdminForUpdateRulesCallOut />
<ReadOnlyAlertsCallOut />
<ReadOnlyRulesCallOut />
<MissingPrivilegesCallOut />
{indicesExist ? (
<StyledFullHeightContainer onKeyDown={onKeyDown} ref={containerElement}>
<EuiWindowEvent event="resize" handler={noop} />

View file

@ -22,7 +22,6 @@ import { SpyRoute } from '../../../../common/utils/route/spy_routes';
import { useUserData } from '../../../components/user_info';
import { AllRules } from './all';
import { ImportDataModal } from '../../../../common/components/import_data_modal';
import { ReadOnlyRulesCallOut } from '../../../components/callouts/read_only_rules_callout';
import { ValueListsModal } from '../../../components/value_lists_management_modal';
import { UpdatePrePackagedRulesCallOut } from '../../../components/rules/pre_packaged_rules/update_callout';
import {
@ -37,6 +36,7 @@ import { LinkButton } from '../../../../common/components/links';
import { useFormatUrl } from '../../../../common/components/link_to';
import { NeedAdminForUpdateRulesCallOut } from '../../../components/callouts/need_admin_for_update_callout';
import { MlJobCompatibilityCallout } from '../../../components/callouts/ml_job_compatibility_callout';
import { MissingPrivilegesCallOut } from '../../../components/callouts/missing_privileges_callout';
type Func = () => Promise<void>;
@ -161,7 +161,7 @@ const RulesPageComponent: React.FC = () => {
return (
<>
<NeedAdminForUpdateRulesCallOut />
<ReadOnlyRulesCallOut />
<MissingPrivilegesCallOut />
<MlJobCompatibilityCallout />
<ValueListsModal
showModal={showValueListsModal}

View file

@ -18750,12 +18750,6 @@
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "クエリ結果をプレビューするデータのタイムフレームを選択します",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "クイッククエリプレビュー",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewSubtitleLoading": "...loading",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageBody.essenceDescription": "アラートを表示する権限のみが付与されています。アラート状態を更新 (アラートを開く、アラートを閉じる) 必要がある場合は、Kibana管理者に連絡してください。",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageBody.messageDetail": "{essence} 関連ドキュメント:{docs}",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageTitle": "アラート状態を変更することはできません",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOut.messageBody.essenceDescription": "現在、検出エンジンルールを作成/編集するための必要な権限がありません。サポートについては、管理者にお問い合わせください。",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOut.messageBody.messageDetail": "{essence} 関連ドキュメント:{docs}",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOut.messageTitle": "ルールアクセス権が必要です",
"xpack.securitySolution.detectionEngine.rule.editRule.errorMsgDescription": "{countError, plural, one {このタブ} other {これらのタブ}}に無効な入力があります:{tabHasError}",
"xpack.securitySolution.detectionEngine.ruleDescription.mlJobStartedDescription": "開始",
"xpack.securitySolution.detectionEngine.ruleDescription.mlJobStoppedDescription": "停止",

View file

@ -19018,12 +19018,6 @@
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "选择数据的时间范围以预览查询结果",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "快速查询预览",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewSubtitleLoading": "...正在加载",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageBody.essenceDescription": "您仅有权查看告警。如果您需要更新告警状态 (打开或关闭告警) ,请联系您的 Kibana 管理员。",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageBody.messageDetail": "{essence} 相关文档:{docs}",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageTitle": "您无法更改告警状态",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOut.messageBody.essenceDescription": "您当前缺少所需的权限,无法创建/编辑检测引擎规则。有关进一步帮助,请联系您的管理员。",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOut.messageBody.messageDetail": "{essence} 相关文档:{docs}",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOut.messageTitle": "需要规则权限",
"xpack.securitySolution.detectionEngine.rule.editRule.errorMsgDescription": "您在{countError, plural, other {以下选项卡}}中的输入无效:{tabHasError}",
"xpack.securitySolution.detectionEngine.ruleDescription.mlJobStartedDescription": "已启动",
"xpack.securitySolution.detectionEngine.ruleDescription.mlJobStoppedDescription": "已停止",