From dd713bac5571738b8c61344dfd4974ce98812e66 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 26 Jun 2023 16:22:13 -0600 Subject: [PATCH] Change permissions used for finishing plugin setup (#2242) Fixes issue where user not having `plugins:install`permission were unable to complete setup of OnCall. - Check multiple Grafana permissions to complete OnCall setup instead of `plugins:install` since the plugin is already installed at this point - Use the following permissions - `plugins:write` - Plugin setup will write to plugin config - `users:read` - Grafana API key being granted to OnCall will be used to read users from Grafana - `teams:read` - Grafana API key being granted to OnCall will be used to read teams from Grafana - `apikeys:create` - If Grafana API key does not exist it will be created - `apikeys:delete` - If existing Grafana API key does not work it will be deleted and recreated Closes https://github.com/grafana/oncall-private/issues/1925 TODO: - [x] Fix tests --- CHANGELOG.md | 6 + .../PluginConfigPage.test.tsx | 4 +- .../PluginConfigPage/PluginConfigPage.tsx | 50 +++-- .../PluginConfigPage.test.tsx.snap | 183 ++++++++++++------ .../plugin/__snapshots__/plugin.test.ts.snap | 28 +-- grafana-plugin/src/state/plugin/index.ts | 77 +++++--- .../src/state/plugin/plugin.test.ts | 8 +- .../src/state/rootBaseStore/index.ts | 85 +++++--- .../state/rootBaseStore/rootBaseStore.test.ts | 141 +++++++++++--- .../src/utils/authorization/index.ts | 6 +- grafana-plugin/src/utils/consts.ts | 8 + 11 files changed, 418 insertions(+), 178 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5b845a77..e254d25a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Change permissions used during setup to better represent actions being taken by @mderynck ([#2242](https://github.com/grafana/oncall/pull/2242)) + ## v1.3.1 (2023-06-26) ### Fixed diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx index bb656de8a3..f186050aad 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx @@ -283,7 +283,7 @@ describe('PluginConfigPage', () => { const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; process.env.ONCALL_API_URL = processEnvOnCallApiUrl; - + window.location.reload = jest.fn(); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce(null); mockSyncDataWithOnCall(License.OSS); @@ -302,8 +302,6 @@ describe('PluginConfigPage', () => { // click the confirm button within the modal, which actually triggers the callback await userEvent.click(screen.getByText('Remove')); - await screen.findByTestId(successful ? PLUGIN_CONFIGURATION_FORM_DATA_ID : STATUS_MESSAGE_BLOCK_DATA_ID); - // assertions expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index 9b44999e56..6b25db8722 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -1,12 +1,11 @@ import React, { FC, useCallback, useEffect, useState } from 'react'; -import { Button, Label, Legend, LoadingPlaceholder } from '@grafana/ui'; +import { Button, HorizontalGroup, Label, Legend, LoadingPlaceholder } from '@grafana/ui'; import { useLocation } from 'react-router-dom'; import { OnCallPluginConfigPageProps } from 'types'; -import logo from 'img/logo.svg'; import PluginState, { PluginStatusResponseBase } from 'state/plugin'; -import { GRAFANA_LICENSE_OSS } from 'utils/consts'; +import { FALLBACK_LICENSE, GRAFANA_LICENSE_OSS } from 'utils/consts'; import ConfigurationForm from './parts/ConfigurationForm'; import RemoveCurrentConfigurationButton from './parts/RemoveCurrentConfigurationButton'; @@ -75,13 +74,13 @@ const PluginConfigPage: FC = ({ const pluginMetaOnCallApiUrl = jsonData?.onCallApiUrl; const processEnvOnCallApiUrl = process.env.ONCALL_API_URL; // don't destructure this, will break how webpack supplies this const onCallApiUrl = pluginMetaOnCallApiUrl || processEnvOnCallApiUrl; - const licenseType = pluginIsConnected?.license; + const licenseType = pluginIsConnected?.license || FALLBACK_LICENSE; const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]); const triggerDataSyncWithOnCall = useCallback(async () => { + resetMessages(); setSyncingPlugin(true); - setSyncError(null); const syncDataResponse = await PluginState.syncDataWithOnCall(onCallApiUrl); @@ -144,35 +143,25 @@ const PluginConfigPage: FC = ({ } }, [pluginMetaOnCallApiUrl, processEnvOnCallApiUrl, onCallApiUrl, pluginConfiguredRedirect]); - const resetState = useCallback(() => { + const resetMessages = useCallback(() => { setPluginResetError(null); setPluginConnectionCheckError(null); setPluginIsConnected(null); setSyncError(null); + }, []); + + const resetState = useCallback(() => { + resetMessages(); resetQueryParams(); }, [resetQueryParams]); - /** - * NOTE: there is a possible edge case when resetting the plugin, that would lead to an error message being shown - * (which could be fixed by just reloading the page) - * This would happen if the user removes the plugin configuration, leaves the page, then comes back to the plugin - * configuration. - * - * This is because the props being passed into this component wouldn't reflect the actual plugin - * provisioning state. The props would still have onCallApiUrl set in the plugin jsonData, so when we make the API - * call to check the plugin state w/ OnCall API the plugin-proxy would return a 502 Bad Gateway because the actual - * provisioned plugin doesn't know about the onCallApiUrl. - * - * This could be fixed by instead of passing in the plugin provisioning information as props always fetching it - * when this component renders (via a useEffect). We probably don't need to worry about this because it should happen - * very rarely, if ever - */ const triggerPluginReset = useCallback(async () => { setResettingPlugin(true); resetState(); try { await PluginState.resetPlugin(); + window.location.reload(); } catch (e) { // this should rarely, if ever happen, but we should handle the case nevertheless setPluginResetError('There was an error resetting your plugin, try again.'); @@ -186,6 +175,15 @@ const PluginConfigPage: FC = ({ [resettingPlugin, triggerPluginReset] ); + const ReconfigurePluginButtons = () => ( + + + {licenseType === GRAFANA_LICENSE_OSS ? : null} + + ); + let content: React.ReactNode; if (checkingIfPluginIsConnected) { @@ -196,16 +194,14 @@ const PluginConfigPage: FC = ({ content = ( <> - + ); } else if (syncError) { content = ( <> - + ); } else if (!pluginIsConnected) { @@ -228,8 +224,8 @@ const PluginConfigPage: FC = ({ {pluginIsConnected ? ( <>

- Plugin is connected! Continue to Grafana OnCall by clicking the{' '} - Grafana OnCall Logo icon over there 👈 + Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over + there 👈

- + + +
+ +
+ `; @@ -152,16 +175,39 @@ exports[`PluginConfigPage If onCallApiUrl is set, and checkIfPluginIsConnected r ohhh nooo a plugin connection error - + + +
+ +
+ `; @@ -173,14 +219,7 @@ exports[`PluginConfigPage It doesn't make any network calls if the plugin config Configure Grafana OnCall

- Plugin is connected! Continue to Grafana OnCall by clicking the - - Grafana OnCall Logo - icon over there 👈 + Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over there 👈

   

- Plugin is connected! Continue to Grafana OnCall by clicking the - - Grafana OnCall Logo - icon over there 👈 + Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over there 👈

   

- Plugin is connected! Continue to Grafana OnCall by clicking the - - Grafana OnCall Logo - icon over there 👈 + Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over there 👈

   
- + + +
+ +
+ `; @@ -334,16 +382,39 @@ exports[`PluginConfigPage Plugin reset: successful - false 1`] = ` There was an error resetting your plugin, try again.
- + + +
+ +
+ `; diff --git a/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap b/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap index 1bb513e312..329fd8fce6 100644 --- a/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap +++ b/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap @@ -1,58 +1,58 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`PluginState.generateInvalidOnCallApiURLErrorMsg it returns the proper error message - configured through env var: false 1`] = ` -"Could not communicate with your OnCall API at http://hello.com. -Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance." +"Could not communicate with OnCall API at http://hello.com. +Validate that the URL is correct, OnCall API is running, and that it is accessible from your Grafana instance." `; exports[`PluginState.generateInvalidOnCallApiURLErrorMsg it returns the proper error message - configured through env var: true 1`] = ` -"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI). -Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance." +"Could not communicate with OnCall API at http://hello.com (NOTE: OnCall API URL is currently being taken from process.env of your UI). +Validate that the URL is correct, OnCall API is running, and that it is accessible from your Grafana instance." `; exports[`PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg it returns the proper error message - configured through env var: false 1`] = `""`; -exports[`PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg it returns the proper error message - configured through env var: true 1`] = `" (NOTE: your OnCall API URL is currently being taken from process.env of your UI)"`; +exports[`PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg it returns the proper error message - configured through env var: true 1`] = `" (NOTE: OnCall API URL is currently being taken from process.env of your UI)"`; exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: false 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: false 2`] = ` -"An unknown error occured when trying to sync the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct? +"An unknown error occurred when trying to sync the plugin. Verify OnCall API URL, http://hello.com, is correct? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: true 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: true 2`] = ` -"An unknown error occured when trying to sync the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to sync the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: false 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: true 1`] = `"ohhhh nooo an error"`; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 409 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 502 1`] = ` -"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI). -Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance." +"Could not communicate with OnCall API at http://hello.com (NOTE: OnCall API URL is currently being taken from process.env of your UI). +Validate that the URL is correct, OnCall API is running, and that it is accessible from your Grafana instance." `; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles an unknown error properly 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; diff --git a/grafana-plugin/src/state/plugin/index.ts b/grafana-plugin/src/state/plugin/index.ts index 26d0f7402a..7a63925ce4 100644 --- a/grafana-plugin/src/state/plugin/index.ts +++ b/grafana-plugin/src/state/plugin/index.ts @@ -48,21 +48,19 @@ class PluginState { static grafanaBackend = getBackendSrv(); static generateOnCallApiUrlConfiguredThroughEnvVarMsg = (isConfiguredThroughEnvVar: boolean): string => - isConfiguredThroughEnvVar - ? ' (NOTE: your OnCall API URL is currently being taken from process.env of your UI)' - : ''; + isConfiguredThroughEnvVar ? ' (NOTE: OnCall API URL is currently being taken from process.env of your UI)' : ''; static generateInvalidOnCallApiURLErrorMsg = (onCallApiUrl: string, isConfiguredThroughEnvVar: boolean): string => - `Could not communicate with your OnCall API at ${onCallApiUrl}${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( + `Could not communicate with OnCall API at ${onCallApiUrl}${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( isConfiguredThroughEnvVar - )}.\nValidate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance.`; + )}.\nValidate that the URL is correct, OnCall API is running, and that it is accessible from your Grafana instance.`; static generateUnknownErrorMsg = ( onCallApiUrl: string, verb: InstallationVerb, isConfiguredThroughEnvVar: boolean ): string => - `An unknown error occured when trying to ${verb} the plugin. Are you sure that your OnCall API URL, ${onCallApiUrl}, is correct${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( + `An unknown error occurred when trying to ${verb} the plugin. Verify OnCall API URL, ${onCallApiUrl}, is correct${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( isConfiguredThroughEnvVar )}?\nRefresh your page and try again, or try removing your plugin configuration and reconfiguring.`; @@ -78,7 +76,7 @@ class PluginState { installationVerb, onCallApiUrlIsConfiguredThroughEnvVar ); - const consoleMsg = `occured while trying to ${installationVerb} the plugin w/ the OnCall backend`; + const consoleMsg = `occurred while trying to ${installationVerb} the plugin w/ the OnCall backend`; if (isNetworkError(e)) { const { status: statusCode } = e.response; @@ -104,7 +102,7 @@ class PluginState { errorMsg = unknownErrorMsg; } } else { - // a non-network related error occured.. this scenario shouldn't occur... + // a non-network related error occurred.. this scenario shouldn't occur... console.warn(`An unknown error ${consoleMsg}`, e); errorMsg = unknownErrorMsg; } @@ -121,11 +119,11 @@ class PluginState { if (isNetworkError(e)) { // The user likely put in a bogus URL for the OnCall API URL - console.warn('An HTTP related error occured while trying to provision the plugin w/ Grafana', e.response); + console.warn('An HTTP related error occurred while trying to provision the plugin w/ Grafana', e.response); errorMsg = this.generateInvalidOnCallApiURLErrorMsg(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); } else { - // a non-network related error occured.. this scenario shouldn't occur... - console.warn('An unknown error occured while trying to provision the plugin w/ Grafana', e); + // a non-network related error occurred.. this scenario shouldn't occur... + console.warn('An unknown error occurred while trying to provision the plugin w/ Grafana', e); errorMsg = this.generateUnknownErrorMsg(onCallApiUrl, installationVerb, onCallApiUrlIsConfiguredThroughEnvVar); } return errorMsg; @@ -137,16 +135,20 @@ class PluginState { static updateGrafanaPluginSettings = async (data: UpdateGrafanaPluginSettingsProps, enabled = true) => this.grafanaBackend.post(this.GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true }); - static createGrafanaToken = async () => { - const baseUrl = '/api/auth/keys'; - const keys = await this.grafanaBackend.get(baseUrl); - const existingKey = keys.find((key: { id: number; name: string; role: string }) => key.name === 'OnCall'); + static readonly KEYS_BASE_URL = '/api/auth/keys'; + + static getGrafanaToken = async () => { + const keys = await this.grafanaBackend.get(this.KEYS_BASE_URL); + return keys.find((key: { id: number; name: string; role: string }) => key.name === 'OnCall'); + }; + static createGrafanaToken = async () => { + const existingKey = await this.getGrafanaToken(); if (existingKey) { - await this.grafanaBackend.delete(`${baseUrl}/${existingKey.id}`); + await this.grafanaBackend.delete(`${this.KEYS_BASE_URL}/${existingKey.id}`); } - return await this.grafanaBackend.post(baseUrl, { + return await this.grafanaBackend.post(this.KEYS_BASE_URL, { name: 'OnCall', role: 'Admin', secondsToLive: null, @@ -205,9 +207,27 @@ class PluginState { onCallApiUrlIsConfiguredThroughEnvVar = false ): Promise => { try { + /** + * Allows the plugin config page to repair settings like the app initialization screen if a user deletes + * an API key on accident but leaves the plugin settings intact. + */ + const existingKey = await this.getGrafanaToken(); + if (!existingKey) { + try { + await this.installPlugin(); + } catch (e) { + return this.getHumanReadableErrorFromOnCallError( + e, + onCallApiUrl, + 'install', + onCallApiUrlIsConfiguredThroughEnvVar + ); + } + } + const startSyncResponse = await makeRequest(`${this.ONCALL_BASE_URL}/sync`, { method: 'POST' }); if (typeof startSyncResponse === 'string') { - // an error occured trying to initiate the sync + // an error occurred trying to initiate the sync return startSyncResponse; } @@ -300,11 +320,22 @@ class PluginState { return null; }; - static checkIfBackendIsInMaintenanceMode = async (): Promise => { - const response = await makeRequest('/maintenance-mode-status', { - method: 'GET', - }); - return response.currently_undergoing_maintenance_message; + static checkIfBackendIsInMaintenanceMode = async ( + onCallApiUrl: string, + onCallApiUrlIsConfiguredThroughEnvVar = false + ): Promise => { + try { + return await makeRequest('/maintenance-mode-status', { + method: 'GET', + }); + } catch (e) { + return this.getHumanReadableErrorFromOnCallError( + e, + onCallApiUrl, + 'install', + onCallApiUrlIsConfiguredThroughEnvVar + ); + } }; static checkIfPluginIsConnected = async ( diff --git a/grafana-plugin/src/state/plugin/plugin.test.ts b/grafana-plugin/src/state/plugin/plugin.test.ts index 7b67fad040..24292a9dad 100644 --- a/grafana-plugin/src/state/plugin/plugin.test.ts +++ b/grafana-plugin/src/state/plugin/plugin.test.ts @@ -383,6 +383,7 @@ describe('PluginState.syncDataWithOnCall', () => { const errorMsg = 'asdfasdf'; makeRequest.mockResolvedValueOnce(errorMsg); + PluginState.getGrafanaToken = jest.fn().mockReturnValueOnce({ id: 1 }); PluginState.pollOnCallDataSyncStatus = jest.fn(); // test @@ -403,6 +404,7 @@ describe('PluginState.syncDataWithOnCall', () => { const mockedPollOnCallDataSyncStatusResponse = 'dfjkdfjdf'; makeRequest.mockResolvedValueOnce(mockedResponse); + PluginState.getGrafanaToken = jest.fn().mockReturnValueOnce({ id: 1 }); PluginState.pollOnCallDataSyncStatus = jest.fn().mockResolvedValueOnce(mockedPollOnCallDataSyncStatusResponse); // test @@ -427,6 +429,7 @@ describe('PluginState.syncDataWithOnCall', () => { const mockedHumanReadableError = 'asdfjkdfjkdfjk'; makeRequest.mockRejectedValueOnce(mockedError); + PluginState.getGrafanaToken = jest.fn().mockReturnValueOnce({ id: 1 }); PluginState.pollOnCallDataSyncStatus = jest.fn(); PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(mockedHumanReadableError); @@ -663,13 +666,14 @@ describe('PluginState.checkIfBackendIsInMaintenanceMode', () => { // mocks const maintenanceModeMsg = 'asdfljkadsjlfkajsdf'; const mockedResp = { currently_undergoing_maintenance_message: maintenanceModeMsg }; + const onCallApiUrl = 'http://hello.com'; makeRequest.mockResolvedValueOnce(mockedResp); // test - const response = await PluginState.checkIfBackendIsInMaintenanceMode(); + const response = await PluginState.checkIfBackendIsInMaintenanceMode(onCallApiUrl); // assertions - expect(response).toEqual(maintenanceModeMsg); + expect(response).toEqual(mockedResp); expect(makeRequest).toHaveBeenCalledTimes(1); expect(makeRequest).toHaveBeenCalledWith('/maintenance-mode-status', { method: 'GET' }); }); diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index b801999224..eaf8804acb 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -1,3 +1,5 @@ +import { OrgRole } from '@grafana/data'; +import { contextSrv } from 'grafana/app/core/core'; import { action, observable } from 'mobx'; import moment from 'moment-timezone'; import qs from 'query-string'; @@ -32,8 +34,7 @@ import { UserGroupStore } from 'models/user_group/user_group'; import { makeRequest } from 'network'; import { AppFeature } from 'state/features'; import PluginState from 'state/plugin'; -import { isUserActionAllowed, UserActions } from 'utils/authorization'; -import { GRAFANA_LICENSE_OSS } from 'utils/consts'; +import { APP_VERSION, CLOUD_VERSION_REGEX, GRAFANA_LICENSE_CLOUD, GRAFANA_LICENSE_OSS } from 'utils/consts'; // ------ Dashboard ------ // @@ -162,13 +163,15 @@ export class RootBaseStore { return this.setupPluginError('🚫 Plugin has not been initialized'); } - const isInMaintenanceMode = await PluginState.checkIfBackendIsInMaintenanceMode(); - if (isInMaintenanceMode !== null) { + const maintenanceMode = await PluginState.checkIfBackendIsInMaintenanceMode(this.onCallApiUrl); + if (typeof maintenanceMode === 'string') { + return this.setupPluginError(maintenanceMode); + } else if (maintenanceMode.currently_undergoing_maintenance_message) { this.currentlyUndergoingMaintenance = true; - return this.setupPluginError(`🚧 ${isInMaintenanceMode} 🚧`); + return this.setupPluginError(`🚧 ${maintenanceMode.currently_undergoing_maintenance_message} 🚧`); } - // at this point we know the plugin is provionsed + // at this point we know the plugin is provisioned const pluginConnectionStatus = await PluginState.checkIfPluginIsConnected(this.onCallApiUrl); if (typeof pluginConnectionStatus === 'string') { return this.setupPluginError(pluginConnectionStatus); @@ -178,28 +181,38 @@ export class RootBaseStore { if (is_user_anonymous) { return this.setupPluginError( - '😞 Unfortunately Grafana OnCall is available for authorized users only, please sign in to proceed.' + '😞 Grafana OnCall is available for authorized users only, please sign in to proceed.' ); } else if (!is_installed || !token_ok) { if (!allow_signup) { return this.setupPluginError('🚫 OnCall has temporarily disabled signup of new users. Please try again later.'); } - - if (!isUserActionAllowed(UserActions.PluginsInstall)) { - return this.setupPluginError( - '🚫 An Admin in your organization must sign on and setup OnCall before it can be used' - ); - } - - try { - /** - * this will install AND sync the necessary data - * the sync is done automatically by the /plugin/install OnCall API endpoint - * therefore there is no need to trigger an additional/separate sync, nor poll a status - */ - await PluginState.installPlugin(); - } catch (e) { - return this.setupPluginError(PluginState.getHumanReadableErrorFromOnCallError(e, this.onCallApiUrl, 'install')); + const missingPermissions = this.checkMissingSetupPermissions(); + if (missingPermissions.length === 0) { + try { + /** + * this will install AND sync the necessary data + * the sync is done automatically by the /plugin/install OnCall API endpoint + * therefore there is no need to trigger an additional/separate sync, nor poll a status + */ + await PluginState.installPlugin(); + } catch (e) { + return this.setupPluginError( + PluginState.getHumanReadableErrorFromOnCallError(e, this.onCallApiUrl, 'install') + ); + } + } else { + if (contextSrv.accessControlEnabled()) { + return this.setupPluginError( + '🚫 User is missing permission(s) ' + + missingPermissions.join(', ') + + ' to setup OnCall before it can be used' + ); + } else { + return this.setupPluginError( + '🚫 User with Admin permissions in your organization must sign on and setup OnCall before it can be used' + ); + } } } else { const syncDataResponse = await PluginState.syncDataWithOnCall(this.onCallApiUrl); @@ -223,13 +236,37 @@ export class RootBaseStore { this.appLoading = false; } + checkMissingSetupPermissions() { + const fallback = contextSrv.user.orgRole === OrgRole.Admin && !contextSrv.accessControlEnabled(); + const setupRequiredPermissions = [ + 'plugins:write', + 'org.users:read', + 'teams:read', + 'apikeys:create', + 'apikeys:delete', + ]; + return setupRequiredPermissions.filter(function (permission) { + return !contextSrv.hasAccess(permission, fallback); + }); + } + hasFeature(feature: string | AppFeature) { // todo use AppFeature only return this.features?.[feature]; } + get license() { + if (this.backendLicense) { + return this.backendLicense; + } + if (CLOUD_VERSION_REGEX.test(APP_VERSION)) { + return GRAFANA_LICENSE_CLOUD; + } + return GRAFANA_LICENSE_OSS; + } + isOpenSource(): boolean { - return this.backendLicense === GRAFANA_LICENSE_OSS; + return this.license === GRAFANA_LICENSE_OSS; } @observable diff --git a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts index ee8a43518b..e3229fc215 100644 --- a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts +++ b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts @@ -1,17 +1,24 @@ +import { OrgRole } from '@grafana/data'; +import { contextSrv } from 'grafana/app/core/core'; import { OnCallAppPluginMeta } from 'types'; import PluginState from 'state/plugin'; -import { UserActions, isUserActionAllowed as isUserActionAllowedOriginal } from 'utils/authorization'; +import { isUserActionAllowed as isUserActionAllowedOriginal } from 'utils/authorization'; import { RootBaseStore } from './'; jest.mock('state/plugin'); jest.mock('utils/authorization'); +jest.mock('grafana/app/core/core', () => ({ + contextSrv: { + user: { + orgRole: null, + }, + }, +})); const isUserActionAllowed = isUserActionAllowedOriginal as jest.Mock>; -const PluginInstallAction = UserActions.PluginsInstall; - const generatePluginData = ( onCallApiUrl: OnCallAppPluginMeta['jsonData']['onCallApiUrl'] = null ): OnCallAppPluginMeta => @@ -42,7 +49,9 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce(errorMsg); // test @@ -62,14 +71,16 @@ describe('rootBaseStore', () => { const rootBaseStore = new RootBaseStore(); const maintenanceMessage = 'mncvnmvcmnvkjdjkd'; - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(maintenanceMessage); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: maintenanceMessage }); // test await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); // assertions expect(PluginState.checkIfBackendIsInMaintenanceMode).toHaveBeenCalledTimes(1); - expect(PluginState.checkIfBackendIsInMaintenanceMode).toHaveBeenCalledWith(); + expect(PluginState.checkIfBackendIsInMaintenanceMode).toHaveBeenCalledWith(onCallApiUrl); expect(rootBaseStore.appLoading).toBe(false); expect(rootBaseStore.initializationError).toEqual(`🚧 ${maintenanceMessage} 🚧`); @@ -81,7 +92,9 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: true, is_installed: true, @@ -100,7 +113,7 @@ describe('rootBaseStore', () => { expect(rootBaseStore.appLoading).toBe(false); expect(rootBaseStore.initializationError).toEqual( - '😞 Unfortunately Grafana OnCall is available for authorized users only, please sign in to proceed.' + '😞 Grafana OnCall is available for authorized users only, please sign in to proceed.' ); }); @@ -109,7 +122,9 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: false, @@ -140,7 +155,13 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + contextSrv.user.orgRole = OrgRole.Viewer; + contextSrv.accessControlEnabled = jest.fn().mockReturnValue(false); + contextSrv.hasAccess = jest.fn().mockReturnValue(false); + + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: false, @@ -159,14 +180,11 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(isUserActionAllowed).toHaveBeenCalledTimes(1); - expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); - expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); expect(rootBaseStore.appLoading).toBe(false); expect(rootBaseStore.initializationError).toEqual( - '🚫 An Admin in your organization must sign on and setup OnCall before it can be used' + '🚫 User with Admin permissions in your organization must sign on and setup OnCall before it can be used' ); }); @@ -179,7 +197,13 @@ describe('rootBaseStore', () => { const rootBaseStore = new RootBaseStore(); const mockedLoadCurrentUser = jest.fn(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + contextSrv.user.orgRole = OrgRole.Admin; + contextSrv.accessControlEnabled = jest.fn().mockResolvedValueOnce(false); + contextSrv.hasAccess = jest.fn().mockReturnValue(true); + + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ ...scenario, is_user_anonymous: false, @@ -198,9 +222,6 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(isUserActionAllowed).toHaveBeenCalledTimes(1); - expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); - expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); expect(PluginState.installPlugin).toHaveBeenCalledWith(); @@ -211,6 +232,71 @@ describe('rootBaseStore', () => { expect(rootBaseStore.initializationError).toBeNull(); }); + test.each([ + { role: OrgRole.Admin, missing_permissions: [], expected_result: true }, + { role: OrgRole.Viewer, missing_permissions: [], expected_result: true }, + { + role: OrgRole.Admin, + missing_permissions: ['plugins:write', 'org.users:read', 'teams:read', 'apikeys:create', 'apikeys:delete'], + expected_result: false, + }, + { + role: OrgRole.Viewer, + missing_permissions: ['plugins:write', 'org.users:read', 'teams:read', 'apikeys:create', 'apikeys:delete'], + expected_result: false, + }, + ])('signup is allowed, accessControlEnabled, various roles and permissions', async (scenario) => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + const mockedLoadCurrentUser = jest.fn(); + + contextSrv.user.orgRole = scenario.role; + contextSrv.accessControlEnabled = jest.fn().mockReturnValue(true); + rootBaseStore.checkMissingSetupPermissions = jest.fn().mockImplementation(() => scenario.missing_permissions); + + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ + ...scenario, + is_user_anonymous: false, + allow_signup: true, + version: 'asdfasdf', + license: 'asdfasdf', + }); + isUserActionAllowed.mockReturnValueOnce(true); + PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); + rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser; + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(rootBaseStore.appLoading).toBe(false); + + if (scenario.expected_result) { + expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.installPlugin).toHaveBeenCalledWith(); + + expect(mockedLoadCurrentUser).toHaveBeenCalledTimes(1); + expect(mockedLoadCurrentUser).toHaveBeenCalledWith(); + + expect(rootBaseStore.initializationError).toBeNull(); + } else { + expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); + + expect(rootBaseStore.initializationError).toEqual( + '🚫 User is missing permission(s) ' + + scenario.missing_permissions.join(', ') + + ' to setup OnCall before it can be used' + ); + } + }); + test('plugin is not installed, signup is allowed, the user is an admin, and plugin installation throws an error', async () => { // mocks/setup const onCallApiUrl = 'http://asdfasdf.com'; @@ -218,7 +304,13 @@ describe('rootBaseStore', () => { const installPluginError = new Error('asdasdfasdfasf'); const humanReadableErrorMsg = 'asdfasldkfjaksdjflk'; - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + contextSrv.user.orgRole = OrgRole.Admin; + contextSrv.accessControlEnabled = jest.fn().mockReturnValue(false); + contextSrv.hasAccess = jest.fn().mockReturnValue(true); + + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: false, @@ -238,9 +330,6 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(isUserActionAllowed).toHaveBeenCalledTimes(1); - expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); - expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); expect(PluginState.installPlugin).toHaveBeenCalledWith(); @@ -263,7 +352,9 @@ describe('rootBaseStore', () => { const version = 'asdfalkjslkjdf'; const license = 'lkjdkjfdkjfdjkfd'; - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: true, @@ -299,7 +390,9 @@ describe('rootBaseStore', () => { const mockedLoadCurrentUser = jest.fn(); const syncDataWithOnCallError = 'asdasdfasdfasf'; - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: true, diff --git a/grafana-plugin/src/utils/authorization/index.ts b/grafana-plugin/src/utils/authorization/index.ts index 10574c47eb..0efc00560f 100644 --- a/grafana-plugin/src/utils/authorization/index.ts +++ b/grafana-plugin/src/utils/authorization/index.ts @@ -25,7 +25,6 @@ export enum Resource { OTHER_SETTINGS = 'other-settings', TEAMS = 'teams', - PLUGINS = 'plugins', } export enum Action { @@ -35,7 +34,6 @@ export enum Action { TEST = 'test', EXPORT = 'export', UPDATE_SETTINGS = 'update-settings', - INSTALL = 'install', } type Actions = @@ -66,8 +64,7 @@ type Actions = | 'UserSettingsAdmin' | 'OtherSettingsRead' | 'OtherSettingsWrite' - | 'TeamsWrite' - | 'PluginsInstall'; + | 'TeamsWrite'; const roleMapping: Record = { [OrgRole.Admin]: 0, @@ -164,5 +161,4 @@ export const UserActions: { [action in Actions]: UserAction } = { // These are not oncall specific TeamsWrite: constructAction(Resource.TEAMS, Action.WRITE, OrgRole.Admin, false), - PluginsInstall: constructAction(Resource.PLUGINS, Action.INSTALL, OrgRole.Admin, false), }; diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index 228e72adee..2ee3b7c067 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -4,9 +4,17 @@ import plugin from '../../package.json'; // eslint-disable-line export const APP_TITLE = 'Grafana OnCall'; export const APP_SUBTITLE = `Developer-friendly incident response (${plugin?.version})`; +export const APP_VERSION = `${plugin?.version}`; + +export const CLOUD_VERSION_REGEX = new RegExp('r[\\d]+-v[\\d]+.[\\d]+.[\\d]+'); + // License export const GRAFANA_LICENSE_OSS = 'OpenSource'; +export const GRAFANA_LICENSE_CLOUD = 'Cloud'; + +export const FALLBACK_LICENSE = CLOUD_VERSION_REGEX.test(APP_VERSION) ? GRAFANA_LICENSE_CLOUD : GRAFANA_LICENSE_OSS; + // height of new Grafana sticky header with breadcrumbs export const GRAFANA_HEADER_HEIGTH = 80;