Skip to content

Commit

Permalink
[Entity Analytics] Add risk engine missing privileges callout (elasti…
Browse files Browse the repository at this point in the history
…c#171250)

## Summary

_note: this is currently behind the experimental feature flag
`riskEnginePrivilegesRouteEnabled`._

Add a callout to the Entity Risk Score Management page if the user
doesn't have sufficient privileges. Here is the callout with a user with
none of the privileges (missing privileges are dynamically shown)

<img width="1177" alt="Screenshot 2023-11-21 at 12 52 21"
src="https://github.com/elastic/kibana/assets/3315046/0c4a17ee-8856-45a5-8798-1cef0e7fe0ad">

as part of this I have added a route `GET
/internal/risk_score/engine/privileges` the response payload looks like
this:
```
{
    "privileges": {
        "kibana": {
            "feature_savedObjectsManagement.all": false
        },
        "elasticsearch": {
            "cluster": {
                "manage_transform": false,
                "manage_index_templates": false
            },
            "index": {
                "risk-score.risk-score-*": {
                    "read": false,
                    "write": false
                }
            }
        }
    },
    "has_all_required": false // does the user have all privileges? 
}
```

Docs issue for associated documentation changes
elastic/security-docs#4307

### Testing    
- cypress tests added for the no banner case (user has all privs), and
the worst case (user has none of the privs)
- API Integration tests added for all of the granular cases 
- Manual test steps
    - 1.  User has correct privileges
        - Create a user with all risk engine privileges
        - navigate to the Entity Risk Score Management page
        - missing privileges banner should not show
    - 2.  User has missing privileges
        - Create a user with some or no risk engine privileges
        - navigate to the Entity Risk Score Management page
        - banner should show and describe all privileges missing

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
hop-dev and kibanamachine authored Nov 28, 2023
1 parent 0b0110a commit a4aa711
Show file tree
Hide file tree
Showing 27 changed files with 764 additions and 2 deletions.
3 changes: 3 additions & 0 deletions packages/kbn-doc-links/src/get_doc_links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
privileges: `${SECURITY_SOLUTION_DOCS}endpoint-management-req.html`,
manageDetectionRules: `${SECURITY_SOLUTION_DOCS}rules-ui-management.html`,
createEsqlRuleType: `${SECURITY_SOLUTION_DOCS}rules-ui-create.html#create-esql-rule`,
entityAnalytics: {
riskScorePrerequisites: `${SECURITY_SOLUTION_DOCS}ers-requirements.html`,
},
},
query: {
eql: `${ELASTICSEARCH_DOCS}eql.html`,
Expand Down
3 changes: 3 additions & 0 deletions packages/kbn-doc-links/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ export interface DocLinks {
readonly privileges: string;
readonly manageDetectionRules: string;
readonly createEsqlRuleType: string;
readonly entityAnalytics: {
readonly riskScorePrerequisites: string;
};
};
readonly query: {
readonly eql: string;
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export const RISK_ENGINE_STATUS_URL = `${RISK_ENGINE_URL}/status`;
export const RISK_ENGINE_INIT_URL = `${RISK_ENGINE_URL}/init`;
export const RISK_ENGINE_ENABLE_URL = `${RISK_ENGINE_URL}/enable`;
export const RISK_ENGINE_DISABLE_URL = `${RISK_ENGINE_URL}/disable`;
export const RISK_ENGINE_PRIVILEGES_URL = `${RISK_ENGINE_URL}/privileges`;

/**
* Public Risk Score routes
Expand Down
12 changes: 12 additions & 0 deletions x-pack/plugins/security_solution/common/experimental_features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ export const allowedExperimentalValues = Object.freeze({
* Enables Protection Updates tab in the Endpoint Policy Details page
*/
protectionUpdatesEnabled: true,

/**
* Disables the timeline save tour.
* This flag is used to disable the tour in cypress tests.
*/
disableTimelineSaveTour: false,

/**
* Enables the risk engine privileges route
* and associated callout in the UI
*/
riskEnginePrivilegesRouteEnabled: false,
});

type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,12 @@
*/

export const MAX_SPACES_COUNT = 1;

export const RISK_ENGINE_REQUIRED_ES_CLUSTER_PRIVILEGES = [
'manage_index_templates',
'manage_transform',
];

export const RISK_ENGINE_REQUIRED_ES_INDEX_PRIVILEGES = Object.freeze({
'risk-score.risk-score-*': ['read', 'write'],
});
17 changes: 17 additions & 0 deletions x-pack/plugins/security_solution/common/test/ess_roles.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,22 @@
"base": []
}
]
},
"no_risk_engine_privileges": {
"name": "no_risk_engine_privileges",
"elasticsearch": {
"cluster": [],
"indices": [],
"run_as": []
},
"kibana": [
{
"feature": {
"siem": ["read"]
},
"spaces": ["*"],
"base": []
}
]
}
}
1 change: 1 addition & 0 deletions x-pack/plugins/security_solution/common/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export enum ROLES {
reader = 'reader',
hunter = 'hunter',
hunter_no_actions = 'hunter_no_actions',
no_risk_engine_privileges = 'no_risk_engine_privileges',
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
RISK_ENGINE_ENABLE_URL,
RISK_ENGINE_DISABLE_URL,
RISK_ENGINE_INIT_URL,
RISK_ENGINE_PRIVILEGES_URL,
} from '../../../common/constants';

import { KibanaServices } from '../../common/lib/kibana';
Expand All @@ -20,6 +21,7 @@ import type {
GetRiskEngineStatusResponse,
InitRiskEngineResponse,
DisableRiskEngineResponse,
RiskEnginePrivilegesResponse,
} from '../../../server/lib/entity_analytics/risk_engine/types';
import type { RiskScorePreviewRequestSchema } from '../../../common/risk_engine/risk_score_preview/request_schema';

Expand Down Expand Up @@ -85,3 +87,13 @@ export const disableRiskEngine = async (): Promise<DisableRiskEngineResponse> =>
method: 'POST',
});
};

/**
* Get risk engine privileges
*/
export const fetchRiskEnginePrivileges = async (): Promise<RiskEnginePrivilegesResponse> => {
return KibanaServices.get().http.fetch<RiskEnginePrivilegesResponse>(RISK_ENGINE_PRIVILEGES_URL, {
version: '1',
method: 'GET',
});
};
Original file line number Diff line number Diff line change
@@ -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 { useQuery } from '@tanstack/react-query';
import { fetchRiskEnginePrivileges } from '../api';

export const useRiskEnginePrivileges = () => {
return useQuery(['GET', 'FETCH_RISK_ENGINE_PRIVILEGES'], fetchRiskEnginePrivileges);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* 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.
*/

export { RiskEnginePrivilegesCallOut } from './risk_engine_privileges_callout';
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 type { CallOutMessage } from '../../../common/components/callouts';
import { CallOutSwitcher } from '../../../common/components/callouts';
import { MissingPrivilegesCallOutBody, MISSING_PRIVILEGES_CALLOUT_TITLE } from './translations';
import { useMissingPrivileges } from './use_missing_risk_engine_privileges';

export const RiskEnginePrivilegesCallOut = () => {
const privileges = useMissingPrivileges();

if (privileges.isLoading || privileges.hasAllRequiredPrivileges) {
return null;
}

const message: CallOutMessage = {
type: 'primary',
id: `missing-risk-engine-privileges`,
title: MISSING_PRIVILEGES_CALLOUT_TITLE,
description: <MissingPrivilegesCallOutBody {...privileges.missingPrivileges} />,
};

return (
message && <CallOutSwitcher namespace="entity_analytics" condition={true} message={message} />
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* 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, EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { useKibana } from '../../../common/lib/kibana';
import { CommaSeparatedValues } from '../../../detections/components/callouts/missing_privileges_callout/comma_separated_values';
import type { MissingPrivileges } from './use_missing_risk_engine_privileges';

export const MISSING_PRIVILEGES_CALLOUT_TITLE = i18n.translate(
'xpack.securitySolution.riskEngine.missingPrivilegesCallOut.messageTitle',
{
defaultMessage: 'Insufficient privileges',
}
);

export const MissingPrivilegesCallOutBody: React.FC<MissingPrivileges> = ({
indexPrivileges,
clusterPrivileges,
}) => {
const { docLinks } = useKibana().services;

return (
<FormattedMessage
id="xpack.securitySolution.riskEngine.missingPrivilegesCallOut.messageBody.messageDetail"
defaultMessage="{essence} {indexPrivileges} {clusterPrivileges} "
values={{
essence: (
<p>
<FormattedMessage
id="xpack.securitySolution.riskEngine.missingPrivilegesCallOut.messageBody.essenceDescription"
defaultMessage="You need the following privileges to fully access this functionality. Contact your administrator for further assistance. Read more about {docs}."
values={{
docs: (
<EuiLink
href={docLinks.links.securitySolution.entityAnalytics.riskScorePrerequisites}
target="_blank"
>
<FormattedMessage
id="xpack.securitySolution.riskEngine.missingPrivilegesCallOut.riskEngineRequirementsDocLink"
defaultMessage="Risk Scoring prerequisites"
/>
</EuiLink>
),
}}
/>
</p>
),
indexPrivileges:
indexPrivileges.length > 0 ? (
<>
<FormattedMessage
id="xpack.securitySolution.riskEngine.missingPrivilegesCallOut.messageBody.indexPrivilegesTitle"
defaultMessage="Missing Elasticsearch index privileges:"
/>
<ul>
{indexPrivileges.map(([index, missingPrivileges]) => (
<li key={index}>{missingIndexPrivileges(index, missingPrivileges)}</li>
))}
</ul>
</>
) : null,
clusterPrivileges:
clusterPrivileges.length > 0 ? (
<>
<FormattedMessage
id="xpack.securitySolution.riskEngine.missingPrivilegesCallOut.messageBody.clusterPrivilegesTitle"
defaultMessage="Missing Elasticsearch cluster privileges:"
/>
<ul>
{clusterPrivileges.map((privilege) => (
<li key={privilege}>{privilege}</li>
))}
</ul>
</>
) : null,
}}
/>
);
};

const missingIndexPrivileges = (index: string, privileges: string[]) => (
<FormattedMessage
id="xpack.securitySolution.riskEngine.missingPrivilegesCallOut.messageBody.missingIndexPrivileges"
defaultMessage="Missing {privileges} privileges for the {index} index."
values={{
privileges: <CommaSeparatedValues values={privileges} />,
index: <EuiCode>{index}</EuiCode>,
}}
/>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* 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 type { RiskEnginePrivilegesResponse } from '../../../../server/lib/entity_analytics/risk_engine/types';
import { useRiskEnginePrivileges } from '../../api/hooks/use_risk_engine_privileges';
import {
RISK_ENGINE_REQUIRED_ES_CLUSTER_PRIVILEGES,
RISK_ENGINE_REQUIRED_ES_INDEX_PRIVILEGES,
} from '../../../../common/risk_engine';

const getMissingIndexPrivileges = (
privileges: RiskEnginePrivilegesResponse['privileges']['elasticsearch']['index']
): MissingIndexPrivileges => {
const missingIndexPrivileges: MissingIndexPrivileges = [];

for (const [indexName, requiredPrivileges] of Object.entries(
RISK_ENGINE_REQUIRED_ES_INDEX_PRIVILEGES
)) {
const missingPrivileges = requiredPrivileges.filter(
(privilege) => !privileges[indexName][privilege]
);

if (missingPrivileges.length) {
missingIndexPrivileges.push([indexName, missingPrivileges]);
}
}

return missingIndexPrivileges;
};

export type MissingClusterPrivileges = string[];
export type MissingIndexPrivileges = Array<[indexName: string, privileges: string[]]>;

export interface MissingPrivileges {
clusterPrivileges: MissingClusterPrivileges;
indexPrivileges: MissingIndexPrivileges;
}

export type MissingPrivilegesResponse =
| { isLoading: true }
| { isLoading: false; hasAllRequiredPrivileges: true }
| { isLoading: false; missingPrivileges: MissingPrivileges; hasAllRequiredPrivileges: false };

export const useMissingPrivileges = (): MissingPrivilegesResponse => {
const { data: privilegesResponse, isLoading } = useRiskEnginePrivileges();

return useMemo<MissingPrivilegesResponse>(() => {
if (isLoading || !privilegesResponse) {
return {
isLoading: true,
};
}

if (privilegesResponse.has_all_required) {
return {
isLoading: false,
hasAllRequiredPrivileges: true,
};
}

const { privileges } = privilegesResponse;
const missinIndexPrivileges = getMissingIndexPrivileges(privileges.elasticsearch.index);
const missingClusterPrivileges = RISK_ENGINE_REQUIRED_ES_CLUSTER_PRIVILEGES.filter(
(privilege) => !privileges.elasticsearch.cluster[privilege]
);

return {
isLoading: false,
hasAllRequiredPrivileges: false,
missingPrivileges: {
indexPrivileges: missinIndexPrivileges,
clusterPrivileges: missingClusterPrivileges,
},
};
}, [isLoading, privilegesResponse]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,11 @@ export const RiskScoreEnableSection = () => {
)}
{!isUpdateAvailable && (
<EuiFlexGroup gutterSize="s" alignItems={'center'}>
<EuiFlexItem>{isLoading && <EuiLoadingSpinner size="m" />}</EuiFlexItem>
<EuiFlexItem>
{isLoading && (
<EuiLoadingSpinner data-test-subj="risk-score-status-loading" size="m" />
)}
</EuiFlexItem>
<EuiFlexItem
css={{ minWidth: MIN_WIDTH_TO_PREVENT_LABEL_FROM_MOVING }}
data-test-subj="risk-score-status"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ import { RiskScorePreviewSection } from '../components/risk_score_preview_sectio
import { RiskScoreEnableSection } from '../components/risk_score_enable_section';
import { ENTITY_ANALYTICS_RISK_SCORE } from '../../app/translations';
import { BETA } from '../../common/translations';
import { RiskEnginePrivilegesCallOut } from '../components/risk_engine_privileges_callout';
import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';

export const EntityAnalyticsManagementPage = () => {
const privilegesCalloutEnabled = useIsExperimentalFeatureEnabled(
'riskEnginePrivilegesRouteEnabled'
);
return (
<>
{privilegesCalloutEnabled && <RiskEnginePrivilegesCallOut />}
<EuiFlexGroup gutterSize="s" alignItems="baseline">
<EuiFlexItem grow={false}>
<EuiPageHeader
Expand Down
Loading

0 comments on commit a4aa711

Please sign in to comment.