Skip to content

Commit

Permalink
[Synthetics] Restrict user with limited Fleet permissions to manipula…
Browse files Browse the repository at this point in the history
…te monitors (#150402)

Closes #137471

## Summary

Adds checks at various points (add, edit, delete, enable, disable
actions) and asserts if user has the necessary Synthetics and Fleet
(Integrations Read/Write) permissions.

- A user with no `capabilities.uptime.save` permission won't be able to
edit, delete, enable or disable a monitor on Overview, Management and
Details page.
- A user with no `fleet.authz.integrations.writeIntegrationPolicies`
permission won't be able to create, update, enable or disable a monitor
with at least one Private Location selected.
- A user with no `fleet.authz.integrations.writeIntegrationPolicies`
permission won't be able to create or delete a Private Location from
Synthetics -> Settings.

Action buttons and menu items will be disabled, with a tooltip conveying
the reason why the button is disabled.

### Testing
1. Setup Kibana and Synthetics and so that you are able to create
monitors with both public and private locations.
2. Create a few monitors using a user having both synthetics and fleet
permissions.
3. Test different actions (create, edit, delete, enable, disable) with a
user having no synthetics permissions.
3. Test different actions with a user having synthetics permissions but
no fleet permissions.

![Screenshot 2023-02-07 at 02 25
53](https://user-images.githubusercontent.com/2748376/217142767-0b82abc2-656c-4f65-9dae-cddaaaac5688.png)
![Screenshot 2023-02-07 at 04 10
43](https://user-images.githubusercontent.com/2748376/217142783-6958836d-11cc-4e82-9879-fc4031f13567.png)
v87
![Screenshot 2023-02-07 at 04 13
06](https://user-images.githubusercontent.com/2748376/217142785-30bf45c8-1e19-4522-af8e-bed32f6948ba.png)
![Screenshot 2023-02-07 at 04 13
23](https://user-images.githubusercontent.com/2748376/217142793-c09a0273-1829-418a-9126-7da296c223ae.png)

---------

Co-authored-by: shahzad31 <[email protected]>
Co-authored-by: Kibana Machine <[email protected]>
  • Loading branch information
3 people authored Feb 9, 2023
1 parent d0b0985 commit 4308929
Show file tree
Hide file tree
Showing 19 changed files with 391 additions and 166 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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, { ReactNode } from 'react';
import { EuiCallOut, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';

export const FleetPermissionsCallout = () => {
return (
<EuiCallOut title={NEED_PERMISSIONS} color="warning" iconType="help">
<p>{NEED_FLEET_READ_AGENT_POLICIES_PERMISSION}</p>
</EuiCallOut>
);
};

/**
* If any of the canEditSynthetics or canUpdatePrivateMonitor is false, then wrap the children with a tooltip
* so that a reason can be conveyed to the user explaining why the action is disabled.
*/
export const NoPermissionsTooltip = ({
canEditSynthetics = true,
canUpdatePrivateMonitor = true,
canAddPrivateMonitor = true,
children,
}: {
canEditSynthetics?: boolean;
canUpdatePrivateMonitor?: boolean;
canAddPrivateMonitor?: boolean;
children: ReactNode;
}) => {
const disabledMessage = getRestrictionReasonLabel(
canEditSynthetics,
canUpdatePrivateMonitor,
canAddPrivateMonitor
);
if (disabledMessage) {
return (
<EuiToolTip content={disabledMessage}>
<span>{children}</span>
</EuiToolTip>
);
}

return <>{children}</>;
};

function getRestrictionReasonLabel(
canEditSynthetics = true,
canUpdatePrivateMonitor = true,
canAddPrivateMonitor = true
): string | undefined {
return !canEditSynthetics
? CANNOT_PERFORM_ACTION_SYNTHETICS
: !canUpdatePrivateMonitor
? CANNOT_PERFORM_ACTION_FLEET
: !canAddPrivateMonitor
? PRIVATE_LOCATIONS_NOT_ALLOWED_MESSAGE
: undefined;
}

export const NEED_PERMISSIONS = i18n.translate(
'xpack.synthetics.monitorManagement.needPermissions',
{
defaultMessage: 'Need permissions',
}
);

export const NEED_FLEET_READ_AGENT_POLICIES_PERMISSION = i18n.translate(
'xpack.synthetics.monitorManagement.needFleetReadAgentPoliciesPermission',
{
defaultMessage:
'You are not authorized to access Fleet. Fleet permissions are required to create new private locations.',
}
);

export const CANNOT_SAVE_INTEGRATION_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.cannotSaveIntegration',
{
defaultMessage:
'You are not authorized to update integrations. Integrations write permissions are required.',
}
);

const CANNOT_PERFORM_ACTION_FLEET = i18n.translate(
'xpack.synthetics.monitorManagement.noFleetPermission',
{
defaultMessage:
'You are not authorized to perform this action. Integrations write permissions are required.',
}
);

const CANNOT_PERFORM_ACTION_SYNTHETICS = i18n.translate(
'xpack.synthetics.monitorManagement.noSyntheticsPermissions',
{
defaultMessage: 'You do not have sufficient permissions to perform this action.',
}
);

const PRIVATE_LOCATIONS_NOT_ALLOWED_MESSAGE = i18n.translate(
'xpack.synthetics.monitorManagement.privateLocationsNotAllowedMessage',
{
defaultMessage:
'You do not have permission to add monitors to private locations. Contact your administrator to request access.',
}
);

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useSimpleMonitor } from './use_simple_monitor';
import { ServiceLocationsField } from './form_fields/service_locations';
import { ConfigKey, ServiceLocations } from '../../../../../common/runtime_types';
import { ConfigKey, ServiceLocation, ServiceLocations } from '../../../../../common/runtime_types';
import { useCanEditSynthetics } from '../../../../hooks/use_capabilities';
import { useFormWrapped } from '../../../../hooks/use_form_wrapped';
import { useFleetPermissions } from '../../hooks';
import { NoPermissionsTooltip } from '../common/components/permissions';

export interface SimpleFormData {
urls: string;
Expand All @@ -32,6 +35,7 @@ export const SimpleMonitorForm = () => {
register,
handleSubmit,
formState: { errors, isValid, isSubmitted },
getValues,
} = useFormWrapped({
mode: 'onSubmit',
reValidateMode: 'onChange',
Expand All @@ -47,6 +51,13 @@ export const SimpleMonitorForm = () => {

const { loading, data: newMonitor } = useSimpleMonitor({ monitorData });

const canEditSynthetics = useCanEditSynthetics();
const { canSaveIntegrations } = useFleetPermissions();
const hasAnyPrivateLocationSelected = (getValues(ConfigKey.LOCATIONS) as ServiceLocations)?.some(
({ isServiceManaged }: ServiceLocation) => !isServiceManaged
);
const canSavePrivateLocation = !hasAnyPrivateLocationSelected || canSaveIntegrations;

const hasURLError = !!errors?.[ConfigKey.URLS];

return (
Expand Down Expand Up @@ -77,15 +88,21 @@ export const SimpleMonitorForm = () => {
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
type="submit"
fill
iconType="plusInCircleFilled"
isLoading={loading}
data-test-subj="syntheticsMonitorConfigSubmitButton"
<NoPermissionsTooltip
canEditSynthetics={canEditSynthetics}
canAddPrivateMonitor={canSavePrivateLocation}
>
{CREATE_MONITOR_LABEL}
</EuiButton>
<EuiButton
type="submit"
fill
iconType="plusInCircleFilled"
isLoading={loading}
data-test-subj="syntheticsMonitorConfigSubmitButton"
disabled={!canEditSynthetics || !canSavePrivateLocation}
>
{CREATE_MONITOR_LABEL}
</EuiButton>
</NoPermissionsTooltip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ import { i18n } from '@kbn/i18n';
import { useFormContext } from 'react-hook-form';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import { RunTestButton } from './run_test_btn';
import { useCanEditSynthetics } from '../../../../../hooks/use_capabilities';
import { useFleetPermissions } from '../../../hooks';
import { useMonitorSave } from '../hooks/use_monitor_save';
import { NoPermissionsTooltip } from '../../common/components/permissions';
import { DeleteMonitor } from '../../monitors_page/management/monitor_list_table/delete_monitor';
import { ConfigKey, SourceType, SyntheticsMonitor } from '../types';
import { ConfigKey, ServiceLocation, SourceType, SyntheticsMonitor } from '../types';
import { format } from './formatter';

import { MONITORS_ROUTE } from '../../../../../../common/constants';
Expand All @@ -25,6 +28,7 @@ export const ActionBar = ({ readOnly = false }: { readOnly: boolean }) => {
const {
handleSubmit,
formState: { errors, defaultValues },
getValues,
} = useFormContext();

const [monitorPendingDeletion, setMonitorPendingDeletion] = useState<SyntheticsMonitor | null>(
Expand All @@ -35,6 +39,13 @@ export const ActionBar = ({ readOnly = false }: { readOnly: boolean }) => {

const { status, loading, isEdit } = useMonitorSave({ monitorData });

const canEditSynthetics = useCanEditSynthetics();
const { canSaveIntegrations } = useFleetPermissions();
const hasAnyPrivateLocationSelected = getValues(ConfigKey.LOCATIONS)?.some(
({ isServiceManaged }: ServiceLocation) => !isServiceManaged
);
const canSavePrivateLocation = !hasAnyPrivateLocationSelected || canSaveIntegrations;

const formSubmitter = (formData: Record<string, any>) => {
if (!Object.keys(errors).length) {
setMonitorData(format(formData, readOnly));
Expand Down Expand Up @@ -68,15 +79,21 @@ export const ActionBar = ({ readOnly = false }: { readOnly: boolean }) => {
<RunTestButton />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
isLoading={loading}
iconType="plusInCircleFilled"
onClick={handleSubmit(formSubmitter)}
data-test-subj="syntheticsMonitorConfigSubmitButton"
<NoPermissionsTooltip
canEditSynthetics={canEditSynthetics}
canAddPrivateMonitor={isEdit || canSavePrivateLocation}
canUpdatePrivateMonitor={!isEdit || canSavePrivateLocation}
>
{isEdit ? UPDATE_MONITOR_LABEL : CREATE_MONITOR_LABEL}
</EuiButton>
<EuiButton
fill
isLoading={loading}
onClick={handleSubmit(formSubmitter)}
data-test-subj="syntheticsMonitorConfigSubmitButton"
disabled={!canEditSynthetics || !canSavePrivateLocation}
>
{isEdit ? UPDATE_MONITOR_LABEL : CREATE_MONITOR_LABEL}
</EuiButton>
</NoPermissionsTooltip>
</EuiFlexItem>
</EuiFlexGroup>
{monitorPendingDeletion && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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 React from 'react';
import { useParams } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { EuiButton } from '@elastic/eui';

import { EncryptedSyntheticsMonitor } from '../../../../../../common/runtime_types';
import { useCanEditSynthetics } from '../../../../../hooks/use_capabilities';
import { useSyntheticsSettingsContext } from '../../../contexts';
import { useCanUpdatePrivateMonitor } from '../../../hooks';
import { NoPermissionsTooltip } from '../../common/components/permissions';
import { useSelectedMonitor } from '../hooks/use_selected_monitor';

export const EditMonitorLink = () => {
const { basePath } = useSyntheticsSettingsContext();

const { monitorId } = useParams<{ monitorId: string }>();
const { monitor } = useSelectedMonitor();

const canEditSynthetics = useCanEditSynthetics();
const canUpdatePrivateMonitor = useCanUpdatePrivateMonitor(monitor as EncryptedSyntheticsMonitor);
const isLinkDisabled = !canEditSynthetics || !canUpdatePrivateMonitor;
const linkProps = isLinkDisabled
? { disabled: true }
: { href: `${basePath}/app/synthetics/edit-monitor/${monitorId}` };

return (
<NoPermissionsTooltip
canEditSynthetics={canEditSynthetics}
canUpdatePrivateMonitor={canUpdatePrivateMonitor}
>
<EuiButton fill iconType="pencil" iconSide="left" {...linkProps}>
{EDIT_MONITOR}
</EuiButton>
</NoPermissionsTooltip>
);
};

const EDIT_MONITOR = i18n.translate('xpack.synthetics.monitorSummary.editMonitor', {
defaultMessage: 'Edit monitor',
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import { EuiIcon, EuiPageHeaderProps } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { MonitorNotFoundPage } from './monitor_not_found_page';
import { MonitorDetailsPageTitle } from './monitor_details_page_title';
import { EditMonitorLink } from '../common/links/edit_monitor';
import { RunTestManually } from './run_test_manually';
import { MonitorDetailsLastRun } from './monitor_details_last_run';
import { MonitorDetailsStatus } from './monitor_details_status';
import { MonitorDetailsLocation } from './monitor_details_location';
import { MonitorErrors } from './monitor_errors/monitor_errors';
import { MonitorHistory } from './monitor_history/monitor_history';
import { MonitorSummary } from './monitor_summary/monitor_summary';
import { EditMonitorLink } from './monitor_summary/edit_monitor_link';
import { MonitorDetailsPage } from './monitor_details_page';
import {
MONITOR_ERRORS_ROUTE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { EuiButton, EuiToolTip } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { useDispatch, useSelector } from 'react-redux';
import { TEST_NOW_ARIA_LABEL, TEST_SCHEDULED_LABEL } from '../monitor_add_edit/form/run_test_btn';
import {
TEST_NOW_ARIA_LABEL,
TEST_SCHEDULED_LABEL,
PRIVATE_AVAILABLE_LABEL,
} from '../monitor_add_edit/form/run_test_btn';
import { useSelectedMonitor } from './hooks/use_selected_monitor';
import {
manualTestMonitorAction,
Expand All @@ -21,20 +25,22 @@ export const RunTestManually = () => {

const { monitor } = useSelectedMonitor();

const hasPublicLocation = () => {
return monitor?.locations.some((loc) => loc.isServiceManaged);
};
const hasPublicLocation = monitor?.locations.some((loc) => loc.isServiceManaged);

const testInProgress = useSelector(manualTestRunInProgressSelector(monitor?.config_id));

const content = testInProgress ? TEST_SCHEDULED_LABEL : TEST_NOW_ARIA_LABEL;
const content = !hasPublicLocation
? PRIVATE_AVAILABLE_LABEL
: testInProgress
? TEST_SCHEDULED_LABEL
: TEST_NOW_ARIA_LABEL;

return (
<EuiToolTip content={content} key={content}>
<EuiButton
color="success"
iconType="beaker"
isDisabled={!hasPublicLocation()}
isDisabled={!hasPublicLocation}
isLoading={!Boolean(monitor) || testInProgress}
onClick={() => {
if (monitor) {
Expand Down
Loading

0 comments on commit 4308929

Please sign in to comment.