Skip to content

Commit

Permalink
RBAC: enforce permissions in frontend using user roles (#986)
Browse files Browse the repository at this point in the history
* feat: create useUserPermissions hook

* feat: define PluginPermissions type

* feat: enforce Check permissions using RBAC

* feat: enforce Probe permissions usign RBAC

* feat: enforce Alert permissions using RBAC

* fix: lint

* feat: enforce Config permissions using RBAC

* feat: apply new permissions to plugin installation

* fix: remove console.log

* fix: fallback to basic user roles contemplating roles hierarchy

* fix: change PluginPermission to use write instead of edit

* fix: add tests

* fix: update types for access-tokens permissions

* fix: lint

* fix: tests

* fix: show missing permissions alert

* fix: adjust types to match plugin definitions

* fix: refactor getUserPermissions function

* fix: change plugin permissions to use template literal types

* fix: uppercase RBAC in function names

* fix: updates after rebasing with main

* fix: adapt after rebasing with main

* fix: remove useCanWriteSM hook

- instead, we should query permissions from getUserPermissions

* fix: lint

* fix: check for metrics ds query access in order to display alerts
  • Loading branch information
VikaCep committed Nov 29, 2024
1 parent f25ab15 commit 24f9857
Show file tree
Hide file tree
Showing 33 changed files with 380 additions and 111 deletions.
6 changes: 3 additions & 3 deletions src/components/AddNewCheckButton/AddNewCheckButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import React from 'react';
import { Button } from '@grafana/ui';

import { ROUTES } from 'routing/types';
import { useCanWriteSM } from 'hooks/useDSPermission';
import { getUserPermissions } from 'data/permissions';
import { useNavigation } from 'hooks/useNavigation';

export function AddNewCheckButton() {
const navigate = useNavigation();
const canEdit = useCanWriteSM();
const { canWriteChecks } = getUserPermissions();

if (!canEdit) {
if (!canWriteChecks) {
return null;
}

Expand Down
18 changes: 12 additions & 6 deletions src/components/AppInitializer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import { DataTestIds } from 'test/dataTestIds';

import { hasGlobalPermission } from 'utils';
import { ROUTES } from 'routing/types';
import { getUserPermissions } from 'data/permissions';

import { useAppInitializer } from 'hooks/useAppInitializer';
import { useMeta } from 'hooks/useMeta';
import { MismatchedDatasourceModal } from 'components/MismatchedDatasourceModal';
import { ContactAdminAlert } from 'page/ContactAdminAlert';

interface Props {
redirectTo?: ROUTES;
Expand All @@ -19,7 +22,10 @@ interface Props {
export const AppInitializer = ({ redirectTo, buttonText }: PropsWithChildren<Props>) => {
const { jsonData } = useMeta();
const styles = useStyles2(getStyles);
const canInitialize = hasGlobalPermission(`datasources:create`);
const { canEnablePlugin } = getUserPermissions();

const meetsMinPermissions = hasGlobalPermission(`datasources:read`);
const canInitialize = canEnablePlugin && hasGlobalPermission(`datasources:create`);

const {
error,
Expand All @@ -35,12 +41,12 @@ export const AppInitializer = ({ redirectTo, buttonText }: PropsWithChildren<Pro
setDataSouceModalOpen,
} = useAppInitializer(redirectTo);

if (!meetsMinPermissions) {
return <ContactAdminAlert permissions={['datasources:read']} />;
}

if (!canInitialize) {
return (
<Alert title="" severity="info">
Contact your administrator to get you started.
</Alert>
);
return <ContactAdminAlert />;
}

return (
Expand Down
7 changes: 4 additions & 3 deletions src/components/CheckForm/CheckForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import { createNavModel } from 'utils';
import { ROUTES } from 'routing/types';
import { generateRoutePath } from 'routing/utils';
import { AdHocCheckResponse } from 'datasource/responses.types';
import { getUserPermissions } from 'data/permissions';
import { useCheckTypeGroupOption } from 'hooks/useCheckTypeGroupOptions';
import { useCheckTypeOptions } from 'hooks/useCheckTypeOptions';
import { useCanReadLogs, useCanWriteSM } from 'hooks/useDSPermission';
import { useCanReadLogs } from 'hooks/useDSPermission';
import { useLimits } from 'hooks/useLimits';
import { toFormValues } from 'components/CheckEditor/checkFormTransformations';
import { CheckJobName } from 'components/CheckEditor/FormComponents/CheckJobName';
Expand Down Expand Up @@ -72,7 +73,7 @@ type CheckFormProps = {
};

export const CheckForm = ({ check, disabled }: CheckFormProps) => {
const canEdit = useCanWriteSM();
const { canWriteChecks } = getUserPermissions();
const canReadLogs = useCanReadLogs();
const [openTestCheckModal, setOpenTestCheckModal] = useState(false);
const [adhocTestData, setAdhocTestData] = useState<AdHocCheckResponse>();
Expand All @@ -90,7 +91,7 @@ export const CheckForm = ({ check, disabled }: CheckFormProps) => {
isOverCheckLimit ||
(checkType === CheckType.Browser && isOverBrowserLimit) ||
([CheckType.MULTI_HTTP, CheckType.Scripted].includes(checkType) && isOverScriptedLimit);
const isDisabled = disabled || !canEdit || getLimitDisabled({ isExistingCheck, isLoading, overLimit });
const isDisabled = disabled || !canWriteChecks || getLimitDisabled({ isExistingCheck, isLoading, overLimit });

const formMethods = useForm<CheckFormValues>({
defaultValues: toFormValues(initialCheck, checkType),
Expand Down
11 changes: 6 additions & 5 deletions src/components/ConfigActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@ import React, { useState } from 'react';
import { getBackendSrv } from '@grafana/runtime';
import { Button, LinkButton } from '@grafana/ui';

import { hasGlobalPermission } from 'utils';
import { ROUTES } from 'routing/types';
import { getRoute } from 'routing/utils';
import { useMeta } from 'hooks/useMeta';
import { usePluginPermissions } from 'hooks/usePluginPermissions';

import { DisablePluginModal } from './DisablePluginModal';

export const ConfigActions = ({ initialized }: { initialized?: boolean }) => {
const [showDisableModal, setShowDisableModal] = useState(false);
const meta = useMeta();
const canEdit = hasGlobalPermission(`plugins:write`);

const { canEnablePlugin, canDisablePlugin, canEditPlugin } = usePluginPermissions();

const handleEnable = async () => {
await getBackendSrv()
Expand All @@ -28,19 +29,19 @@ export const ConfigActions = ({ initialized }: { initialized?: boolean }) => {
window.location.reload();
};

if (!canEdit) {
if (!canEditPlugin) {
return null;
}

if (!meta.enabled) {
if (!meta.enabled && canEnablePlugin) {
return (
<Button variant="primary" onClick={handleEnable}>
Enable plugin
</Button>
);
}

if (initialized) {
if (initialized && canDisablePlugin) {
return (
<>
<Button variant="destructive" onClick={() => setShowDisableModal(true)}>
Expand Down
10 changes: 6 additions & 4 deletions src/components/DeleteProbeButton/DeleteProbeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ export function DeleteProbeButton({ probe, onDeleteSuccess: _onDeleteSuccess }:
}, [_onDeleteSuccess]);

const { mutateAsync: deleteProbe, isPending } = useDeleteProbe({ onSuccess: onDeleteSuccess });
const canEdit = useCanEditProbe(probe);
const canDelete = canEdit && !probe.checks.length;

const { canDeleteProbes } = useCanEditProbe();

const canDelete = canDeleteProbes && !probe.checks.length;
const styles = getStyles();
const [error, setError] = useState<undefined | { name: string; message: string }>();

Expand All @@ -37,7 +39,7 @@ export function DeleteProbeButton({ probe, onDeleteSuccess: _onDeleteSuccess }:
};

if (!canDelete) {
const tooltipContent = canEdit ? (
const tooltipContent = canDeleteProbes ? (
<>
Unable to delete the probe because it is currently in use.
<br />
Expand All @@ -53,7 +55,7 @@ export function DeleteProbeButton({ probe, onDeleteSuccess: _onDeleteSuccess }:

// Both tooltip component and button prob is used for accessibility reasons
return (
<Tooltip content={tooltipContent} interactive={canEdit && !canDelete}>
<Tooltip content={tooltipContent} interactive={canDeleteProbes && !canDelete}>
<Button type="button" variant="destructive" tooltip={tooltipContent} disabled>
Delete probe
</Button>
Expand Down
5 changes: 3 additions & 2 deletions src/components/LinkedDatasourceView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import { Alert, Card, Tag } from '@grafana/ui';

import { useCanWriteLogs, useCanWriteMetrics, useCanWriteSM } from 'hooks/useDSPermission';
import { getUserPermissions } from 'data/permissions';
import { useCanWriteLogs, useCanWriteMetrics } from 'hooks/useDSPermission';
import { useLogsDS } from 'hooks/useLogsDS';
import { useMetricsDS } from 'hooks/useMetricsDS';
import { useSMDS } from 'hooks/useSMDS';
Expand All @@ -15,7 +16,7 @@ export const LinkedDatasourceView = ({ type }: LinkedDatasourceViewProps) => {
const logsDS = useLogsDS();
const smDS = useSMDS();

const canEditSM = useCanWriteSM();
const { canWriteSM: canEditSM } = getUserPermissions();
const canEditLogs = useCanWriteLogs();
const canEditMetrics = useCanWriteMetrics();

Expand Down
14 changes: 13 additions & 1 deletion src/components/ProbeCard/ProbeCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { userEvent } from '@testing-library/user-event';
import { DataTestIds } from 'test/dataTestIds';
import { OFFLINE_PROBE, ONLINE_PROBE, PRIVATE_PROBE, PUBLIC_PROBE } from 'test/fixtures/probes';
import { render } from 'test/render';
import { probeToExtendedProbe, runTestAsViewer } from 'test/utils';
import { probeToExtendedProbe, runTestAsRBACReader, runTestAsViewer } from 'test/utils';

import { type ExtendedProbe } from 'types';
import { ROUTES } from 'routing/types';
Expand Down Expand Up @@ -92,6 +92,18 @@ it(`Displays the correct information for a private probe as a viewer`, async ()
expect(button).toHaveTextContent('View');
});

it(`Displays the correct information for a private probe as a RBAC viewer`, async () => {
runTestAsRBACReader();
const probe = probeToExtendedProbe(PRIVATE_PROBE);

render(<ProbeCard probe={probe} />);
await screen.findByText(probe.name, { exact: false });

const button = screen.getByTestId('probe-card-action-button');
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent('View');
});

it(`Displays the correct information for a public probe`, async () => {
const probe = probeToExtendedProbe(PUBLIC_PROBE);

Expand Down
6 changes: 3 additions & 3 deletions src/components/ProbeCard/ProbeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { ProbeLabels } from './ProbeLabels';
import { ProbeStatus } from './ProbeStatus';

export const ProbeCard = ({ probe }: { probe: ExtendedProbe }) => {
const canEdit = useCanEditProbe(probe);
const probeEditHref = generateRoutePath(canEdit ? ROUTES.EditProbe : ROUTES.ViewProbe, { id: probe.id! });
const { canWriteProbes } = useCanEditProbe(probe);
const probeEditHref = generateRoutePath(canWriteProbes ? ROUTES.EditProbe : ROUTES.ViewProbe, { id: probe.id! });
const labelsString = labelsToString(probe.labels);
const styles = useStyles2(getStyles2);

Expand Down Expand Up @@ -55,7 +55,7 @@ export const ProbeCard = ({ probe }: { probe: ExtendedProbe }) => {
</Card.Description>

<Card.Actions>
{canEdit ? (
{canWriteProbes ? (
<>
<LinkButton
data-testid="probe-card-action-button"
Expand Down
14 changes: 13 additions & 1 deletion src/components/ProbeEditor/ProbeEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { config } from '@grafana/runtime';
import { screen } from '@testing-library/react';
import { PRIVATE_PROBE, PUBLIC_PROBE } from 'test/fixtures/probes';
import { render } from 'test/render';
import { fillProbeForm, probeToExtendedProbe, runTestAsViewer, UPDATED_VALUES } from 'test/utils';
import { fillProbeForm, probeToExtendedProbe, runTestAsRBACReader, runTestAsViewer, UPDATED_VALUES } from 'test/utils';

import { ExtendedProbe, FeatureName, Probe } from 'types';
import { TEMPLATE_PROBE } from 'page/NewProbe';
Expand Down Expand Up @@ -108,6 +108,12 @@ it('the form is uneditable when logged in as a viewer', async () => {
await assertUneditable();
});

it('the form is uneditable when logged in as a RBAC viewer', async () => {
runTestAsRBACReader();
await renderProbeEditor();
await assertUneditable();
});

it('the form actions are unavailable when viewing a public probe', async () => {
await renderProbeEditor({ probe: PUBLIC_PROBE });
await assertNoActions();
Expand All @@ -124,6 +130,12 @@ it('should render the form in read mode when passing `forceReadMode`', async ()
await assertUneditable();
});

it('the form actions are unavailable as a RBAC viewer', async () => {
runTestAsRBACReader();
await renderProbeEditor();
await assertNoActions();
});

async function assertUneditable() {
const nameInput = await screen.findByLabelText('Probe Name', { exact: false });
expect(nameInput).toBeDisabled();
Expand Down
8 changes: 4 additions & 4 deletions src/components/ProbeEditor/ProbeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ export const ProbeEditor = ({
forceViewMode, // When true, the form is in view mode
}: ProbeEditorProps) => {
const styles = useStyles2(getStyles);
const canEdit = useCanEditProbe(probe);
const writeMode = canEdit && !forceViewMode;
const { canWriteProbes } = useCanEditProbe(probe);
const writeMode = canWriteProbes && !forceViewMode;
const form = useForm<Probe>({ defaultValues: probe, resolver: zodResolver(ProbeSchema) });
const { latitude, longitude } = form.watch();
const handleSubmit = form.handleSubmit((formValues: Probe) => onSubmit(formValues));
Expand Down Expand Up @@ -164,7 +164,7 @@ export const ProbeEditor = ({
/>
</Field>
</div>
{canEdit && <LabelField<Probe> disabled={!writeMode} labelDestination={'probe'} />}
{canWriteProbes && <LabelField<Probe> disabled={!writeMode} labelDestination={'probe'} />}
<div className={styles.marginBottom}>
<Legend>Capabilities</Legend>
<HorizontalCheckboxField
Expand All @@ -189,7 +189,7 @@ export const ProbeEditor = ({
</FeatureFlag>
</div>
<div className={styles.buttonWrapper}>
{canEdit && (
{canWriteProbes && (
<>
<Button
icon={loading ? 'fa fa-spinner' : undefined}
Expand Down
10 changes: 9 additions & 1 deletion src/components/ProbeStatus/ProbeStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { OFFLINE_PROBE, ONLINE_PROBE, PRIVATE_PROBE } from 'test/fixtures/probes';
import { render } from 'test/render';
import { probeToExtendedProbe, runTestAsViewer } from 'test/utils';
import { probeToExtendedProbe, runTestAsRBACReader, runTestAsViewer } from 'test/utils';

import { formatDate } from 'utils';

Expand All @@ -16,6 +16,14 @@ it(`hides the reset button when the user is a viewer`, async () => {
expect(resetButton).not.toBeInTheDocument();
});

it(`hides the reset button when the user is a RBAC viewer`, async () => {
runTestAsRBACReader();
// We need to wait for contexts to finish loading to avoid issue with act
await waitFor(() => render(<ProbeStatus probe={probeToExtendedProbe(PRIVATE_PROBE)} onReset={jest.fn()} />));
const resetButton = await getResetButton(true);
expect(resetButton).not.toBeInTheDocument();
});

it(`shows the reset probe access token when the user is an editor`, async () => {
render(<ProbeStatus probe={probeToExtendedProbe(PRIVATE_PROBE)} onReset={jest.fn()} />);
const resetButton = await getResetButton();
Expand Down
6 changes: 3 additions & 3 deletions src/components/ProbeStatus/ProbeStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ interface BadgeStatus {

export const ProbeStatus = ({ probe, onReset, readOnly }: ProbeStatusProps) => {
const [showResetModal, setShowResetModal] = useState(false);
const canEdit = useCanEditProbe(probe);
const writeMode = canEdit && !readOnly;
const { canWriteProbes } = useCanEditProbe(probe);
const writeMode = canWriteProbes && !readOnly;

const styles = useStyles2(getStyles);
const { mutate: onResetToken } = useResetProbeToken({
Expand All @@ -51,7 +51,7 @@ export const ProbeStatus = ({ probe, onReset, readOnly }: ProbeStatusProps) => {
<Legend className={styles.legend}>Status:</Legend>
<Badge color={badgeStatus.color} icon={badgeStatus.icon} text={badgeStatus.text} />
</div>
{canEdit && (
{canWriteProbes && (
<Container>
<Button variant="destructive" disabled={!writeMode} onClick={() => setShowResetModal(true)}>
Reset Access Token
Expand Down
57 changes: 57 additions & 0 deletions src/data/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { OrgRole } from '@grafana/data';
import { config } from '@grafana/runtime';

import { PluginPermissions } from 'types';

const roleHierarchy: Record<OrgRole, OrgRole[]> = {
[OrgRole.Viewer]: [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin],
[OrgRole.Editor]: [OrgRole.Editor, OrgRole.Admin],
[OrgRole.Admin]: [OrgRole.Admin],
[OrgRole.None]: [],
};

const hasMinFallbackRole = (fallbackOrgRole: OrgRole) => {
const { orgRole } = config.bootData.user;

if (!orgRole) {
return false;
}

return roleHierarchy[fallbackOrgRole]?.includes(orgRole) || false;
};

const isUserActionAllowed = (permission: PluginPermissions, fallbackOrgRole: OrgRole): boolean => {
const { permissions: userPermissions } = config.bootData.user;

if (config.featureToggles.accessControlOnCall) {
return Boolean(userPermissions?.[permission]);
}

return hasMinFallbackRole(fallbackOrgRole);
};

export const getUserPermissions = () => ({
canReadChecks: isUserActionAllowed('grafana-synthetic-monitoring-app.checks:read', OrgRole.Viewer),
canWriteChecks: isUserActionAllowed('grafana-synthetic-monitoring-app.checks:write', OrgRole.Editor),
canDeleteChecks: isUserActionAllowed('grafana-synthetic-monitoring-app.checks:delete', OrgRole.Editor),

canReadProbes: isUserActionAllowed('grafana-synthetic-monitoring-app.probes:read', OrgRole.Viewer),
canWriteProbes: isUserActionAllowed('grafana-synthetic-monitoring-app.probes:write', OrgRole.Editor),
canDeleteProbes: isUserActionAllowed('grafana-synthetic-monitoring-app.probes:delete', OrgRole.Editor),

canReadAlerts: isUserActionAllowed('grafana-synthetic-monitoring-app.alerts:read', OrgRole.Viewer),
canWriteAlerts: isUserActionAllowed('grafana-synthetic-monitoring-app.alerts:write', OrgRole.Editor),
canDeleteAlerts: isUserActionAllowed('grafana-synthetic-monitoring-app.alerts:delete', OrgRole.Editor),

canReadThresholds: isUserActionAllowed('grafana-synthetic-monitoring-app.thresholds:read', OrgRole.Viewer),
canWriteThresholds: isUserActionAllowed('grafana-synthetic-monitoring-app.thresholds:write', OrgRole.Editor),

canReadTokens: isUserActionAllowed('grafana-synthetic-monitoring-app.access-tokens:read', OrgRole.Admin),
canWriteTokens: isUserActionAllowed('grafana-synthetic-monitoring-app.access-tokens:write', OrgRole.Admin),
canDeleteTokens: isUserActionAllowed('grafana-synthetic-monitoring-app.access-tokens:delete', OrgRole.Admin),

canEnablePlugin: isUserActionAllowed('grafana-synthetic-monitoring-app.plugin:enable', OrgRole.Admin),
canDisablePlugin: isUserActionAllowed('grafana-synthetic-monitoring-app.plugin:disable', OrgRole.Admin),

canWriteSM: isUserActionAllowed('grafana-synthetic-monitoring-app:write', OrgRole.Editor),
});
Loading

0 comments on commit 24f9857

Please sign in to comment.