[SecuritySolution][Detections] Fix "Closing a signal silently fails with reduced privileges" (#86908) (#87413)

## Summary

This PR introduces the following changes. If the user has insufficient write privileges on the signals index:

- we disable the status-changing actions on detection alerts ("Open alert", "Close Alert", "Mark in progress") in the context menu of an alert in alerts table
- we make sure to show the corresponding callout that tells about read-only access to detection alerts
- in the callout we provide links to docs for understanding why/how to fix

Co-authored-by: Georgii Gorbachev <georgii.gorbachev@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yara Tercero 2021-01-06 00:23:25 -05:00 committed by GitHub
parent 14fcfba0cb
commit 4867610afb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 265 additions and 66 deletions

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, memo } from 'react';
import { useKibana } from '../../lib/kibana';
import { ExternalLink } from './external_link';
import { COMMON_ARIA_LABEL_ENDING } from './links_translations';
interface DocLinkProps {
guidePath?: string;
docPath: string;
linkText: string;
}
const DocLink: FC<DocLinkProps> = ({ guidePath = 'security', docPath, linkText }) => {
const { services } = useKibana();
const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = services.docLinks;
const url = `${ELASTIC_WEBSITE_URL}guide/en/${guidePath}/${DOC_LINK_VERSION}/${docPath}`;
const ariaLabel = `${linkText} - ${COMMON_ARIA_LABEL_ENDING}`;
return <ExternalLink url={url} text={linkText} ariaLabel={ariaLabel} />;
};
/**
* A simple text link to documentation.
*/
const DocLinkWrapper = memo(DocLink);
export { DocLinkWrapper as DocLink };

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { EuiLink } from '@elastic/eui';
interface ExternalLinkProps {
url: string;
text: string;
ariaLabel?: string;
}
/**
* A simplistic text link for opening external urls in a new browser tab.
*/
export const ExternalLink: FC<ExternalLinkProps> = ({ url, text, ariaLabel }) => {
return (
<EuiLink href={url} aria-label={ariaLabel} external target="_blank" rel="noopener">
{text}
</EuiLink>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './links_components';

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { DocLink } from './doc_link';
import * as i18n from './links_translations';
export const SecuritySolutionRequirementsLink = () => (
<DocLink
docPath={i18n.SOLUTION_REQUIREMENTS_LINK_PATH}
linkText={i18n.SOLUTION_REQUIREMENTS_LINK_TEXT}
/>
);
export const DetectionsRequirementsLink = () => (
<DocLink
docPath={i18n.DETECTIONS_REQUIREMENTS_LINK_PATH}
linkText={i18n.DETECTIONS_REQUIREMENTS_LINK_TEXT}
/>
);

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
/**
* If a link's text is "Docs", its aria-label will be set to
* "Docs - ${COMMON_ARIA_LABEL_ENDING}".
*/
export const COMMON_ARIA_LABEL_ENDING = i18n.translate(
'xpack.securitySolution.documentationLinks.ariaLabelEnding',
{
defaultMessage: 'click to open documentation in a new tab',
}
);
export const SOLUTION_REQUIREMENTS_LINK_PATH = 'sec-requirements.html';
export const SOLUTION_REQUIREMENTS_LINK_TEXT = i18n.translate(
'xpack.securitySolution.documentationLinks.solutionRequirements.text',
{
defaultMessage: 'Elastic Security system requirements',
}
);
export const DETECTIONS_REQUIREMENTS_LINK_PATH = 'detections-permissions-section.html';
export const DETECTIONS_REQUIREMENTS_LINK_TEXT = i18n.translate(
'xpack.securitySolution.documentationLinks.detectionsRequirements.text',
{
defaultMessage: 'Detections prerequisites and requirements',
}
);

View file

@ -98,7 +98,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
setPopover(false);
}, []);
const [exceptionModalType, setOpenAddExceptionModal] = useState<ExceptionListType | null>(null);
const [{ canUserCRUD, hasIndexWrite }] = useUserData();
const [{ canUserCRUD, hasIndexWrite, hasIndexUpdateDelete }] = useUserData();
const isEndpointAlert = useMemo((): boolean => {
if (ecsRowData == null) {
@ -218,7 +218,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
data-test-subj="open-alert-status"
id={FILTER_OPEN}
onClick={openAlertActionOnClick}
disabled={!canUserCRUD || !hasIndexWrite}
disabled={!canUserCRUD || !hasIndexUpdateDelete}
>
<EuiText size="m">{i18n.ACTION_OPEN_ALERT}</EuiText>
</EuiContextMenuItem>
@ -251,7 +251,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
data-test-subj="close-alert-status"
id={FILTER_CLOSED}
onClick={closeAlertActionClick}
disabled={!canUserCRUD || !hasIndexWrite}
disabled={!canUserCRUD || !hasIndexUpdateDelete}
>
<EuiText size="m">{i18n.ACTION_CLOSE_ALERT}</EuiText>
</EuiContextMenuItem>
@ -284,7 +284,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
data-test-subj="in-progress-alert-status"
id={FILTER_IN_PROGRESS}
onClick={inProgressAlertActionClick}
disabled={!canUserCRUD || !hasIndexWrite}
disabled={!canUserCRUD || !hasIndexUpdateDelete}
>
<EuiText size="m">{i18n.ACTION_IN_PROGRESS_ALERT}</EuiText>
</EuiContextMenuItem>

View file

@ -14,16 +14,16 @@ const readOnlyAccessToAlertsMessage: CallOutMessage = {
type: 'primary',
id: 'read-only-access-to-alerts',
title: i18n.READ_ONLY_ALERTS_CALLOUT_TITLE,
description: <p>{i18n.READ_ONLY_ALERTS_CALLOUT_MSG}</p>,
description: i18n.readOnlyAlertsCallOutBody(),
};
const ReadOnlyAlertsCallOutComponent = () => {
const [{ hasIndexWrite }] = useUserData();
const [{ hasIndexUpdateDelete }] = useUserData();
return (
<CallOutSwitcher
namespace="detections"
condition={hasIndexWrite != null && !hasIndexWrite}
condition={hasIndexUpdateDelete != null && !hasIndexUpdateDelete}
message={readOnlyAccessToAlertsMessage}
/>
);

View file

@ -1,22 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const READ_ONLY_ALERTS_CALLOUT_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.readOnlyAlertsCallOutTitle',
{
defaultMessage: 'You cannot change alert states',
}
);
export const READ_ONLY_ALERTS_CALLOUT_MSG = i18n.translate(
'xpack.securitySolution.detectionEngine.readOnlyAlertsCallOutMsg',
{
defaultMessage:
'You only have permissions to view alerts. If you need to update alert states (open or close alerts), contact your Kibana administrator.',
}
);

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;
* you may not use this file except in compliance with the Elastic License.
*/
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

@ -14,7 +14,7 @@ const readOnlyAccessToRulesMessage: CallOutMessage = {
type: 'primary',
id: 'read-only-access-to-rules',
title: i18n.READ_ONLY_RULES_CALLOUT_TITLE,
description: <p>{i18n.READ_ONLY_RULES_CALLOUT_MSG}</p>,
description: i18n.readOnlyRulesCallOutBody(),
};
const ReadOnlyRulesCallOutComponent = () => {

View file

@ -1,22 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const READ_ONLY_RULES_CALLOUT_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.readOnlyRulesCallOutTitle',
{
defaultMessage: 'Rule permissions required',
}
);
export const READ_ONLY_RULES_CALLOUT_MSG = i18n.translate(
'xpack.securitySolution.detectionEngine.readOnlyRulesCallOutMsg',
{
defaultMessage:
'You are currently missing the required permissions to create/edit detection engine rule. Please contact your administrator for further assistance.',
}
);

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;
* you may not use this file except in compliance with the Elastic License.
*/
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

@ -38,6 +38,7 @@ describe('useUserInfo', () => {
hasEncryptionKey: null,
hasIndexManage: null,
hasIndexWrite: null,
hasIndexUpdateDelete: null,
isAuthenticated: null,
isSignalIndexExists: null,
loading: true,

View file

@ -15,6 +15,7 @@ export interface State {
canUserCRUD: boolean | null;
hasIndexManage: boolean | null;
hasIndexWrite: boolean | null;
hasIndexUpdateDelete: boolean | null;
isSignalIndexExists: boolean | null;
isAuthenticated: boolean | null;
hasEncryptionKey: boolean | null;
@ -27,6 +28,7 @@ export const initialState: State = {
canUserCRUD: null,
hasIndexManage: null,
hasIndexWrite: null,
hasIndexUpdateDelete: null,
isSignalIndexExists: null,
isAuthenticated: null,
hasEncryptionKey: null,
@ -45,6 +47,10 @@ export type Action =
type: 'updateHasIndexWrite';
hasIndexWrite: boolean | null;
}
| {
type: 'updateHasIndexUpdateDelete';
hasIndexUpdateDelete: boolean | null;
}
| {
type: 'updateIsSignalIndexExists';
isSignalIndexExists: boolean | null;
@ -90,6 +96,12 @@ export const userInfoReducer = (state: State, action: Action): State => {
hasIndexWrite: action.hasIndexWrite,
};
}
case 'updateHasIndexUpdateDelete': {
return {
...state,
hasIndexUpdateDelete: action.hasIndexUpdateDelete,
};
}
case 'updateIsSignalIndexExists': {
return {
...state,
@ -151,6 +163,7 @@ export const useUserInfo = (): State => {
canUserCRUD,
hasIndexManage,
hasIndexWrite,
hasIndexUpdateDelete,
isSignalIndexExists,
isAuthenticated,
hasEncryptionKey,
@ -166,6 +179,7 @@ export const useUserInfo = (): State => {
hasEncryptionKey: isApiEncryptionKey,
hasIndexManage: hasApiIndexManage,
hasIndexWrite: hasApiIndexWrite,
hasIndexUpdateDelete: hasApiIndexUpdateDelete,
} = usePrivilegeUser();
const {
loading: indexNameLoading,
@ -197,6 +211,19 @@ export const useUserInfo = (): State => {
}
}, [dispatch, loading, hasIndexWrite, hasApiIndexWrite]);
useEffect(() => {
if (
!loading &&
hasIndexUpdateDelete !== hasApiIndexUpdateDelete &&
hasApiIndexUpdateDelete != null
) {
dispatch({
type: 'updateHasIndexUpdateDelete',
hasIndexUpdateDelete: hasApiIndexUpdateDelete,
});
}
}, [dispatch, loading, hasIndexUpdateDelete, hasApiIndexUpdateDelete]);
useEffect(() => {
if (
!loading &&
@ -272,6 +299,7 @@ export const useUserInfo = (): State => {
canUserCRUD,
hasIndexManage,
hasIndexWrite,
hasIndexUpdateDelete,
signalIndexName,
signalIndexMappingOutdated,
};

View file

@ -21,6 +21,7 @@ describe('usePrivilegeUser', () => {
hasEncryptionKey: null,
hasIndexManage: null,
hasIndexWrite: null,
hasIndexUpdateDelete: null,
isAuthenticated: null,
loading: true,
});
@ -38,6 +39,7 @@ describe('usePrivilegeUser', () => {
hasEncryptionKey: true,
hasIndexManage: true,
hasIndexWrite: true,
hasIndexUpdateDelete: true,
isAuthenticated: true,
loading: false,
});
@ -59,6 +61,7 @@ describe('usePrivilegeUser', () => {
hasEncryptionKey: false,
hasIndexManage: false,
hasIndexWrite: false,
hasIndexUpdateDelete: false,
isAuthenticated: false,
loading: false,
});

View file

@ -16,6 +16,7 @@ export interface ReturnPrivilegeUser {
hasEncryptionKey: boolean | null;
hasIndexManage: boolean | null;
hasIndexWrite: boolean | null;
hasIndexUpdateDelete: boolean | null;
}
/**
* Hook to get user privilege from
@ -23,16 +24,12 @@ export interface ReturnPrivilegeUser {
*/
export const usePrivilegeUser = (): ReturnPrivilegeUser => {
const [loading, setLoading] = useState(true);
const [privilegeUser, setPrivilegeUser] = useState<
Pick<
ReturnPrivilegeUser,
'isAuthenticated' | 'hasEncryptionKey' | 'hasIndexManage' | 'hasIndexWrite'
>
>({
const [privilegeUser, setPrivilegeUser] = useState<Omit<ReturnPrivilegeUser, 'loading'>>({
isAuthenticated: null,
hasEncryptionKey: null,
hasIndexManage: null,
hasIndexWrite: null,
hasIndexUpdateDelete: null,
});
const [, dispatchToaster] = useStateToaster();
@ -59,6 +56,7 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => {
privilege.index[indexName].create_doc ||
privilege.index[indexName].index ||
privilege.index[indexName].write,
hasIndexUpdateDelete: privilege.index[indexName].write,
});
}
}
@ -69,6 +67,7 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => {
hasEncryptionKey: false,
hasIndexManage: false,
hasIndexWrite: false,
hasIndexUpdateDelete: false,
});
errorToToaster({ title: i18n.PRIVILEGE_FETCH_FAILURE, error, dispatchToaster });
}

View file

@ -16843,8 +16843,7 @@
"xpack.securitySolution.detectionEngine.noApiIntegrationKeyCallOutMsg": "Kibanaを起動するごとに保存されたオブジェクトの新しい暗号化キーを作成します。永続キーがないと、Kibanaの再起動後にルールを削除または修正することができません。永続キーを設定するには、kibana.ymlファイルに32文字以上のテキスト値を付けてxpack.encryptedSavedObjects.encryptionKey設定を追加してください。",
"xpack.securitySolution.detectionEngine.noApiIntegrationKeyCallOutTitle": "API統合キーが必要です",
"xpack.securitySolution.detectionEngine.noIndexTitle": "検出エンジンを設定しましょう",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOutMsg": "アラートを表示する権限のみが付与されています。アラート状態を更新アラートを開く、アラートを閉じる必要がある場合は、Kibana管理者に連絡してください。",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOutTitle": "アラート状態を変更することはできません",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageTitle": "アラート状態を変更することはできません",
"xpack.securitySolution.detectionEngine.pageTitle": "検出エンジン",
"xpack.securitySolution.detectionEngine.panelSubtitleShowing": "表示中",
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphCountLabel": "カウント",
@ -16859,8 +16858,7 @@
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} {hits, plural, =1 {ヒット} other {ヒット}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "クエリ結果をプレビューするデータのタイムフレームを選択します",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "クイッククエリプレビュー",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOutMsg": "現在、検出エンジンルールを作成/編集するための必要な権限がありません。サポートについては、管理者にお問い合わせください。",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOutTitle": "ルールアクセス権が必要です",
"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

@ -16860,8 +16860,7 @@
"xpack.securitySolution.detectionEngine.noApiIntegrationKeyCallOutMsg": "每次启动 Kibana都会为已保存对象生成新的加密密钥。没有持久性密钥在 Kibana 重新启动后,将无法删除或修改规则。要设置持久性密钥,请将文本值为 32 个或更多任意字符的 xpack.encryptedSavedObjects.encryptionKey 设置添加到 kibana.yml 文件。",
"xpack.securitySolution.detectionEngine.noApiIntegrationKeyCallOutTitle": "需要 API 集成密钥",
"xpack.securitySolution.detectionEngine.noIndexTitle": "让我们来设置您的检测引擎",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOutMsg": "您仅有权查看告警。如果您需要更新告警状态(打开或关闭告警),请联系您的 Kibana 管理员。",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOutTitle": "您无法更改告警状态",
"xpack.securitySolution.detectionEngine.readOnlyAlertsCallOut.messageTitle": "您无法更改告警状态",
"xpack.securitySolution.detectionEngine.pageTitle": "检测引擎",
"xpack.securitySolution.detectionEngine.panelSubtitleShowing": "正在显示",
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphCountLabel": "计数",
@ -16876,8 +16875,7 @@
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} 个{hits, plural, =1 {命中} other {命中}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "选择数据的时间范围以预览查询结果",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "快速查询预览",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOutMsg": "您当前缺少所需的权限,无法创建/编辑检测引擎规则。有关进一步帮助,请联系您的管理员。",
"xpack.securitySolution.detectionEngine.readOnlyRulesCallOutTitle": "需要规则权限",
"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": "已停止",