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
This commit is contained in:
Michael Derynck 2023-06-26 16:22:13 -06:00 committed by GitHub
parent d23e2f44da
commit dd713bac55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 418 additions and 178 deletions

View file

@ -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

View file

@ -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);

View file

@ -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<OnCallPluginConfigPageProps> = ({
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<OnCallPluginConfigPageProps> = ({
}
}, [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<OnCallPluginConfigPageProps> = ({
[resettingPlugin, triggerPluginReset]
);
const ReconfigurePluginButtons = () => (
<HorizontalGroup>
<Button variant="primary" onClick={triggerDataSyncWithOnCall} size="md">
Retry Sync
</Button>
{licenseType === GRAFANA_LICENSE_OSS ? <RemoveConfigButton /> : null}
</HorizontalGroup>
);
let content: React.ReactNode;
if (checkingIfPluginIsConnected) {
@ -196,16 +194,14 @@ const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
content = (
<>
<StatusMessageBlock text={pluginConnectionCheckError || pluginResetError} />
<RemoveConfigButton />
<ReconfigurePluginButtons />
</>
);
} else if (syncError) {
content = (
<>
<StatusMessageBlock text={syncError} />
<Button variant="primary" onClick={triggerDataSyncWithOnCall} size="md">
Retry Sync
</Button>
<ReconfigurePluginButtons />
</>
);
} else if (!pluginIsConnected) {
@ -228,8 +224,8 @@ const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
{pluginIsConnected ? (
<>
<p>
Plugin is connected! Continue to Grafana OnCall by clicking the{' '}
<img alt="Grafana OnCall Logo" src={logo} width={18} /> icon over there 👈
Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over
there 👈
</p>
<StatusMessageBlock
text={`Connected to OnCall (${pluginIsConnected.version}, ${pluginIsConnected.license})`}

View file

@ -19,16 +19,39 @@ exports[`PluginConfigPage If onCallApiUrl is not set in the plugin's meta jsonDa
ohhh nooo an error msg from self hosted install plugin
</span>
</pre>
<button
class="css-1ed0qk5-button"
type="button"
<div
class="css-ve64a7-horizontal-group"
style="width: 100%; height: 100%;"
>
<span
class="css-1mhnkuh"
<div
class="css-cvef6c-layoutChildrenWrapper"
>
Remove current configuration
</span>
</button>
<button
class="css-z53gi5-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Retry Sync
</span>
</button>
</div>
<div
class="css-cvef6c-layoutChildrenWrapper"
>
<button
class="css-1ed0qk5-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
</div>
</div>
`;
@ -152,16 +175,39 @@ exports[`PluginConfigPage If onCallApiUrl is set, and checkIfPluginIsConnected r
ohhh nooo a plugin connection error
</span>
</pre>
<button
class="css-1ed0qk5-button"
type="button"
<div
class="css-ve64a7-horizontal-group"
style="width: 100%; height: 100%;"
>
<span
class="css-1mhnkuh"
<div
class="css-cvef6c-layoutChildrenWrapper"
>
Remove current configuration
</span>
</button>
<button
class="css-z53gi5-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Retry Sync
</span>
</button>
</div>
<div
class="css-cvef6c-layoutChildrenWrapper"
>
<button
class="css-1ed0qk5-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
</div>
</div>
`;
@ -173,14 +219,7 @@ exports[`PluginConfigPage It doesn't make any network calls if the plugin config
Configure Grafana OnCall
</legend>
<p>
Plugin is connected! Continue to Grafana OnCall by clicking the
<img
alt="Grafana OnCall Logo"
src="[object Object]"
width="18"
/>
icon over there 👈
Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over there 👈
</p>
<pre
data-testid="status-message-block"
@ -212,14 +251,7 @@ exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall does not r
Configure Grafana OnCall
</legend>
<p>
Plugin is connected! Continue to Grafana OnCall by clicking the
<img
alt="Grafana OnCall Logo"
src="[object Object]"
width="18"
/>
icon over there 👈
Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over there 👈
</p>
<pre
data-testid="status-message-block"
@ -251,14 +283,7 @@ exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall does not r
Configure Grafana OnCall
</legend>
<p>
Plugin is connected! Continue to Grafana OnCall by clicking the
<img
alt="Grafana OnCall Logo"
src="[object Object]"
width="18"
/>
icon over there 👈
Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over there 👈
</p>
<pre
data-testid="status-message-block"
@ -302,16 +327,39 @@ exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall returns an
ohhh noooo a sync issue
</span>
</pre>
<button
class="css-z53gi5-button"
type="button"
<div
class="css-ve64a7-horizontal-group"
style="width: 100%; height: 100%;"
>
<span
class="css-1mhnkuh"
<div
class="css-cvef6c-layoutChildrenWrapper"
>
Retry Sync
</span>
</button>
<button
class="css-z53gi5-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Retry Sync
</span>
</button>
</div>
<div
class="css-cvef6c-layoutChildrenWrapper"
>
<button
class="css-1ed0qk5-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
</div>
</div>
`;
@ -334,16 +382,39 @@ exports[`PluginConfigPage Plugin reset: successful - false 1`] = `
There was an error resetting your plugin, try again.
</span>
</pre>
<button
class="css-1ed0qk5-button"
type="button"
<div
class="css-ve64a7-horizontal-group"
style="width: 100%; height: 100%;"
>
<span
class="css-1mhnkuh"
<div
class="css-cvef6c-layoutChildrenWrapper"
>
Remove current configuration
</span>
</button>
<button
class="css-z53gi5-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Retry Sync
</span>
</button>
</div>
<div
class="css-cvef6c-layoutChildrenWrapper"
>
<button
class="css-1ed0qk5-button"
type="button"
>
<span
class="css-1mhnkuh"
>
Remove current configuration
</span>
</button>
</div>
</div>
</div>
`;

View file

@ -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."
`;

View file

@ -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<PluginSyncStatusResponse | string> => {
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<string> => {
const response = await makeRequest<PluginIsInMaintenanceModeResponse>('/maintenance-mode-status', {
method: 'GET',
});
return response.currently_undergoing_maintenance_message;
static checkIfBackendIsInMaintenanceMode = async (
onCallApiUrl: string,
onCallApiUrlIsConfiguredThroughEnvVar = false
): Promise<PluginIsInMaintenanceModeResponse | string> => {
try {
return await makeRequest<PluginIsInMaintenanceModeResponse>('/maintenance-mode-status', {
method: 'GET',
});
} catch (e) {
return this.getHumanReadableErrorFromOnCallError(
e,
onCallApiUrl,
'install',
onCallApiUrlIsConfiguredThroughEnvVar
);
}
};
static checkIfPluginIsConnected = async (

View file

@ -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' });
});

View file

@ -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

View file

@ -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<ReturnType<typeof isUserActionAllowedOriginal>>;
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,

View file

@ -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, number> = {
[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),
};

View file

@ -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;